feat: add template pages
This commit is contained in:
17
apps/web/src/pages/templates/[...boardId]/index.tsx
Normal file
17
apps/web/src/pages/templates/[...boardId]/index.tsx
Normal 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;
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { NextPageWithLayout } from "~/pages/_app";
|
import type { NextPageWithLayout } from "~/pages/_app";
|
||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import Popup from "~/components/Popup";
|
import Popup from "~/components/Popup";
|
||||||
import TemplatesView from "~/views/templates";
|
import BoardsView from "~/views/boards";
|
||||||
|
|
||||||
const TemplatesPage: NextPageWithLayout = () => {
|
const TemplatesPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TemplatesView />
|
<BoardsView isTemplate />
|
||||||
<Popup />
|
<Popup />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +1,76 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
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 Dropdown from "~/components/Dropdown";
|
||||||
import { useModal } from "~/providers/modal";
|
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 { 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 (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
items={[
|
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`,
|
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
|
||||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Delete board`,
|
|
||||||
action: () => openModal("DELETE_BOARD"),
|
action: () => openModal("DELETE_BOARD"),
|
||||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
@@ -6,8 +7,10 @@ import { api } from "~/utils/api";
|
|||||||
|
|
||||||
export function DeleteBoardConfirmation({
|
export function DeleteBoardConfirmation({
|
||||||
boardPublicId,
|
boardPublicId,
|
||||||
|
isTemplate,
|
||||||
}: {
|
}: {
|
||||||
boardPublicId: string;
|
boardPublicId: string;
|
||||||
|
isTemplate: boolean;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { closeModal } = useModal();
|
const { closeModal } = useModal();
|
||||||
@@ -15,7 +18,7 @@ export function DeleteBoardConfirmation({
|
|||||||
const deleteBoard = api.board.delete.useMutation({
|
const deleteBoard = api.board.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
router.push(`/boards`);
|
router.push(isTemplate ? `/templates` : `/boards`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,18 +33,18 @@ export function DeleteBoardConfirmation({
|
|||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex w-full flex-col justify-between pb-4">
|
<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">
|
<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>
|
</h2>
|
||||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||||
<Button onClick={() => closeModal()} variant="secondary">
|
<Button onClick={() => closeModal()} variant="secondary">
|
||||||
Cancel
|
{t`Cancel`}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleDeleteBoard} isLoading={deleteBoard.isPending}>
|
<Button onClick={handleDeleteBoard} isLoading={deleteBoard.isPending}>
|
||||||
Delete
|
{t`Delete`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import {
|
|||||||
import type { NewCardInput } from "@kan/api/types";
|
import type { NewCardInput } from "@kan/api/types";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
|
import type { WorkspaceMember } from "~/components/Editor";
|
||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||||
import Editor, { WorkspaceMember } from "~/components/Editor";
|
import Editor from "~/components/Editor";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
import LabelIcon from "~/components/LabelIcon";
|
import LabelIcon from "~/components/LabelIcon";
|
||||||
import Toggle from "~/components/Toggle";
|
import Toggle from "~/components/Toggle";
|
||||||
@@ -35,12 +36,14 @@ interface QueryParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface NewCardFormProps {
|
interface NewCardFormProps {
|
||||||
|
isTemplate: boolean;
|
||||||
boardPublicId: string;
|
boardPublicId: string;
|
||||||
listPublicId: string;
|
listPublicId: string;
|
||||||
queryParams: QueryParams;
|
queryParams: QueryParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewCardForm({
|
export function NewCardForm({
|
||||||
|
isTemplate,
|
||||||
boardPublicId,
|
boardPublicId,
|
||||||
listPublicId,
|
listPublicId,
|
||||||
queryParams,
|
queryParams,
|
||||||
@@ -85,14 +88,13 @@ export function NewCardForm({
|
|||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
}, [watch, saveFormState]);
|
}, [watch, saveFormState]);
|
||||||
|
|
||||||
|
|
||||||
const { data: boardData } = api.board.byId.useQuery(queryParams, {
|
const { data: boardData } = api.board.byId.useQuery(queryParams, {
|
||||||
enabled: !!boardPublicId,
|
enabled: !!boardPublicId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// this adds the new created label to selected labels
|
// this adds the new created label to selected labels
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newLabelId = modalStates["NEW_LABEL_CREATED"];
|
const newLabelId = modalStates.NEW_LABEL_CREATED;
|
||||||
if (newLabelId !== undefined && !labelPublicIds.includes(newLabelId)) {
|
if (newLabelId !== undefined && !labelPublicIds.includes(newLabelId)) {
|
||||||
setValue("labelPublicIds", [...labelPublicIds, newLabelId]);
|
setValue("labelPublicIds", [...labelPublicIds, newLabelId]);
|
||||||
}
|
}
|
||||||
@@ -101,23 +103,23 @@ export function NewCardForm({
|
|||||||
// this removes the deleted label from selected labels if it is selected
|
// this removes the deleted label from selected labels if it is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (boardData?.labels) {
|
if (boardData?.labels) {
|
||||||
const availableLabelIds = boardData.labels.map(label => label.publicId);
|
const availableLabelIds = boardData.labels.map((label) => label.publicId);
|
||||||
const newLabelId = modalStates["NEW_LABEL_CREATED"];
|
const newLabelId = modalStates.NEW_LABEL_CREATED;
|
||||||
|
|
||||||
if (newLabelId && availableLabelIds.includes(newLabelId)) {
|
if (newLabelId && availableLabelIds.includes(newLabelId)) {
|
||||||
clearModalState("NEW_LABEL_CREATED");
|
clearModalState("NEW_LABEL_CREATED");
|
||||||
}
|
}
|
||||||
|
|
||||||
const validLabelIds = labelPublicIds.filter(id =>
|
const validLabelIds = labelPublicIds.filter(
|
||||||
availableLabelIds.includes(id) || id === newLabelId
|
(id) => availableLabelIds.includes(id) || id === newLabelId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (validLabelIds.length !== labelPublicIds.length) {
|
if (validLabelIds.length !== labelPublicIds.length) {
|
||||||
setValue("labelPublicIds", validLabelIds);
|
setValue("labelPublicIds", validLabelIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [boardData?.labels, labelPublicIds, modalStates["NEW_LABEL_CREATED"]]);
|
}, [boardData?.labels, labelPublicIds, modalStates.NEW_LABEL_CREATED]);
|
||||||
|
|
||||||
const createCard = api.card.create.useMutation({
|
const createCard = api.card.create.useMutation({
|
||||||
onMutate: async (args) => {
|
onMutate: async (args) => {
|
||||||
await utils.board.byId.cancel();
|
await utils.board.byId.cancel();
|
||||||
@@ -323,15 +325,19 @@ export function NewCardForm({
|
|||||||
saveFormState({ ...formState, description: value });
|
saveFormState({ ...formState, description: value });
|
||||||
}}
|
}}
|
||||||
workspaceMembers={
|
workspaceMembers={
|
||||||
boardData?.workspace.members?.map((member): WorkspaceMember => ({
|
boardData?.workspace.members?.map(
|
||||||
publicId: member.publicId,
|
(member): WorkspaceMember => ({
|
||||||
email: member.email,
|
publicId: member.publicId,
|
||||||
user: member.user ? {
|
email: member.email,
|
||||||
id: member.publicId,
|
user: member.user
|
||||||
name: member.user.name,
|
? {
|
||||||
image: member.user.image ?? null,
|
id: member.publicId,
|
||||||
} : null,
|
name: member.user.name,
|
||||||
})) ?? []
|
image: member.user.image ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}),
|
||||||
|
) ?? []
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,42 +353,46 @@ export function NewCardForm({
|
|||||||
</div>
|
</div>
|
||||||
</CheckboxDropdown>
|
</CheckboxDropdown>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-fit">
|
{!isTemplate && (
|
||||||
<CheckboxDropdown
|
<div className="w-fit">
|
||||||
items={formattedMembers}
|
<CheckboxDropdown
|
||||||
handleSelect={(_groupKey, item) => handleSelectMembers(item.key)}
|
items={formattedMembers}
|
||||||
>
|
handleSelect={(_groupKey, item) =>
|
||||||
<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">
|
handleSelectMembers(item.key)
|
||||||
{!memberPublicIds.length ? (
|
}
|
||||||
t`Members`
|
>
|
||||||
) : (
|
<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">
|
||||||
<div className="flex -space-x-1 overflow-hidden">
|
{!memberPublicIds.length ? (
|
||||||
{memberPublicIds.map((memberPublicId) => {
|
t`Members`
|
||||||
const member = formattedMembers.find(
|
) : (
|
||||||
(member) => member.key === memberPublicId,
|
<div className="flex -space-x-1 overflow-hidden">
|
||||||
);
|
{memberPublicIds.map((memberPublicId) => {
|
||||||
|
const member = formattedMembers.find(
|
||||||
|
(member) => member.key === memberPublicId,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={member?.key}
|
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"
|
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">
|
<span className="text-[8px] font-medium leading-none text-white">
|
||||||
{member?.value
|
{member?.value
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.map((namePart) =>
|
.map((namePart) =>
|
||||||
namePart.charAt(0).toUpperCase(),
|
namePart.charAt(0).toUpperCase(),
|
||||||
)
|
)
|
||||||
.join("")}
|
.join("")}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</CheckboxDropdown>
|
||||||
</CheckboxDropdown>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="w-fit">
|
<div className="w-fit">
|
||||||
<CheckboxDropdown
|
<CheckboxDropdown
|
||||||
items={formattedLabels}
|
items={formattedLabels}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import VisibilityButton from "./components/VisibilityButton";
|
|||||||
|
|
||||||
type PublicListId = string;
|
type PublicListId = string;
|
||||||
|
|
||||||
export default function BoardPage() {
|
export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||||
const params = useParams() as { boardId: string[] } | null;
|
const params = useParams() as { boardId: string[] } | null;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = api.useUtils();
|
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 ?? "",
|
boardPublicId: boardId ?? "",
|
||||||
members: formatToArray(router.query.members),
|
members: formatToArray(router.query.members),
|
||||||
labels: formatToArray(router.query.labels),
|
labels: formatToArray(router.query.labels),
|
||||||
|
type: isTemplate ? "template" : "regular",
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -208,7 +214,7 @@ export default function BoardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onDragEnd = ({
|
const onDragEnd = ({
|
||||||
source,
|
source: _source,
|
||||||
destination,
|
destination,
|
||||||
draggableId,
|
draggableId,
|
||||||
type,
|
type,
|
||||||
@@ -241,7 +247,10 @@ export default function BoardPage() {
|
|||||||
modalSize="sm"
|
modalSize="sm"
|
||||||
isVisible={isOpen && modalContentType === "DELETE_BOARD"}
|
isVisible={isOpen && modalContentType === "DELETE_BOARD"}
|
||||||
>
|
>
|
||||||
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
|
<DeleteBoardConfirmation
|
||||||
|
isTemplate={!!isTemplate}
|
||||||
|
boardPublicId={boardId ?? ""}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -259,6 +268,7 @@ export default function BoardPage() {
|
|||||||
isVisible={isOpen && modalContentType === "NEW_CARD"}
|
isVisible={isOpen && modalContentType === "NEW_CARD"}
|
||||||
>
|
>
|
||||||
<NewCardForm
|
<NewCardForm
|
||||||
|
isTemplate={!!isTemplate}
|
||||||
boardPublicId={boardId ?? ""}
|
boardPublicId={boardId ?? ""}
|
||||||
listPublicId={selectedPublicListId}
|
listPublicId={selectedPublicListId}
|
||||||
queryParams={queryParams}
|
queryParams={queryParams}
|
||||||
@@ -328,7 +338,7 @@ export default function BoardPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead
|
<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">
|
<div className="relative flex h-full flex-col">
|
||||||
<PatternedBackground />
|
<PatternedBackground />
|
||||||
@@ -354,31 +364,38 @@ export default function BoardPage() {
|
|||||||
)}
|
)}
|
||||||
{!boardData && !isLoading && (
|
{!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">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="order-1 mb-4 flex items-center justify-end space-x-2 md:order-2 md:mb-0">
|
<div className="order-1 mb-4 flex items-center justify-end space-x-2 md:order-2 md:mb-0">
|
||||||
<UpdateBoardSlugButton
|
{!isTemplate && (
|
||||||
handleOnClick={() => openModal("UPDATE_BOARD_SLUG")}
|
<>
|
||||||
isLoading={isLoading}
|
<UpdateBoardSlugButton
|
||||||
workspaceSlug={workspace.slug ?? ""}
|
handleOnClick={() => openModal("UPDATE_BOARD_SLUG")}
|
||||||
boardSlug={boardData?.slug ?? ""}
|
isLoading={isLoading}
|
||||||
/>
|
workspaceSlug={workspace.slug ?? ""}
|
||||||
<VisibilityButton
|
boardSlug={boardData?.slug ?? ""}
|
||||||
visibility={boardData?.visibility ?? "private"}
|
/>
|
||||||
boardPublicId={boardId ?? ""}
|
<VisibilityButton
|
||||||
boardSlug={boardData?.slug ?? ""}
|
visibility={boardData?.visibility ?? "private"}
|
||||||
queryParams={queryParams}
|
boardPublicId={boardId ?? ""}
|
||||||
isLoading={!boardData}
|
boardSlug={boardData?.slug ?? ""}
|
||||||
isAdmin={workspace.role === "admin"}
|
queryParams={queryParams}
|
||||||
/>
|
isLoading={!boardData}
|
||||||
<Filters
|
isAdmin={workspace.role === "admin"}
|
||||||
labels={boardData?.labels ?? []}
|
/>
|
||||||
members={boardData?.workspace.members?.filter(member => member.user !== null) ?? []}
|
{boardData && (
|
||||||
position="left"
|
<Filters
|
||||||
isLoading={!boardData}
|
labels={boardData.labels}
|
||||||
/>
|
members={boardData.workspace.members.filter(
|
||||||
|
(member) => member.user !== null,
|
||||||
|
)}
|
||||||
|
position="left"
|
||||||
|
isLoading={!boardData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<HiOutlinePlusSmall
|
<HiOutlinePlusSmall
|
||||||
@@ -393,7 +410,12 @@ export default function BoardPage() {
|
|||||||
>
|
>
|
||||||
{t`New list`}
|
{t`New list`}
|
||||||
</Button>
|
</Button>
|
||||||
<BoardDropdown isLoading={!boardData} />
|
<BoardDropdown
|
||||||
|
isTemplate={!!isTemplate}
|
||||||
|
isLoading={!boardData}
|
||||||
|
boardPublicId={boardId ?? ""}
|
||||||
|
workspacePublicId={workspace.publicId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -491,7 +513,7 @@ export default function BoardPage() {
|
|||||||
title={card.title}
|
title={card.title}
|
||||||
labels={card.labels}
|
labels={card.labels}
|
||||||
members={card.members}
|
members={card.members}
|
||||||
checklists={card.checklists ?? []}
|
checklists={card.checklists}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ import { useModal } from "~/providers/modal";
|
|||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
export function BoardsList() {
|
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
|
||||||
const { data, isLoading } = api.board.all.useQuery(
|
const { data, isLoading } = api.board.all.useQuery(
|
||||||
{ workspacePublicId: workspace.publicId },
|
{
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
type: isTemplate ? "template" : "regular",
|
||||||
|
},
|
||||||
{ enabled: workspace.publicId ? true : false },
|
{ enabled: workspace.publicId ? true : false },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -32,14 +35,14 @@ export function BoardsList() {
|
|||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
|
<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">
|
<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>
|
||||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => openModal("NEW_BOARD")}>
|
<Button onClick={() => openModal("NEW_BOARD")}>
|
||||||
{t`Create new board`}
|
{t`Create new ${isTemplate ? "template" : "board"}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -47,7 +50,10 @@ export function BoardsList() {
|
|||||||
return (
|
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">
|
<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) => (
|
{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">
|
<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 />
|
<PatternedBackground />
|
||||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import Toggle from "~/components/Toggle";
|
|||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import TemplateBoards, { getTemplates } from "./TemplateBoards";
|
import TemplateBoards from "./TemplateBoards";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z
|
name: z
|
||||||
@@ -29,13 +29,15 @@ interface NewBoardInputWithTemplate {
|
|||||||
template: Template | null;
|
template: Template | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewBoardForm() {
|
export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { closeModal } = useModal();
|
const { closeModal } = useModal();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const [showTemplates, setShowTemplates] = useState(false);
|
const [showTemplates, setShowTemplates] = useState(false);
|
||||||
|
const { data: templates } = api.board.all.useQuery(
|
||||||
const templates = getTemplates();
|
{ workspacePublicId: workspace.publicId ?? "", type: "template" },
|
||||||
|
{ enabled: !!workspace.publicId },
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -69,6 +71,7 @@ export function NewBoardForm() {
|
|||||||
workspacePublicId: data.workspacePublicId,
|
workspacePublicId: data.workspacePublicId,
|
||||||
lists: data.template?.lists ?? [],
|
lists: data.template?.lists ?? [],
|
||||||
labels: data.template?.labels ?? [],
|
labels: data.template?.labels ?? [],
|
||||||
|
type: isTemplate ? "template" : "regular",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,7 +85,7 @@ export function NewBoardForm() {
|
|||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<div className="px-5 pt-5">
|
<div className="px-5 pt-5">
|
||||||
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
|
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}
|
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">
|
<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
|
{!isTemplate && (
|
||||||
label={t`Use template`}
|
<Toggle
|
||||||
isChecked={showTemplates}
|
label={t`Use template`}
|
||||||
onChange={() => {
|
isChecked={showTemplates}
|
||||||
setShowTemplates(!showTemplates);
|
onChange={() => {
|
||||||
if (!showTemplates && !currentTemplate) {
|
setShowTemplates(!showTemplates);
|
||||||
setValue("template", templates[0] ?? null);
|
if (!showTemplates && !currentTemplate) {
|
||||||
}
|
setValue("template", (templates?.[0] as any) ?? null);
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<Button type="submit" isLoading={createBoard.isPending}>
|
<Button type="submit" isLoading={createBoard.isPending}>
|
||||||
{t`Create board`}
|
{t`Create ${isTemplate ? "template" : "board"}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { BoardsList } from "./components/BoardsList";
|
|||||||
import { ImportBoardsForm } from "./components/ImportBoardsForm";
|
import { ImportBoardsForm } from "./components/ImportBoardsForm";
|
||||||
import { NewBoardForm } from "./components/NewBoardForm";
|
import { NewBoardForm } from "./components/NewBoardForm";
|
||||||
|
|
||||||
export default function BoardsPage() {
|
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||||
const { openModal, modalContentType, isOpen } = useModal();
|
const { openModal, modalContentType, isOpen } = useModal();
|
||||||
const { availableWorkspaces, workspace, hasLoaded } = useWorkspace();
|
const { availableWorkspaces, workspace, hasLoaded } = useWorkspace();
|
||||||
|
|
||||||
@@ -25,23 +25,27 @@ export default function BoardsPage() {
|
|||||||
|
|
||||||
return (
|
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="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">
|
<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]">
|
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||||
{t`Boards`}
|
{t`${isTemplate ? "Templates" : "Boards"}`}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
{!isTemplate && (
|
||||||
type="button"
|
<Button
|
||||||
variant="secondary"
|
type="button"
|
||||||
onClick={() => openModal("IMPORT_BOARDS")}
|
variant="secondary"
|
||||||
iconLeft={
|
onClick={() => openModal("IMPORT_BOARDS")}
|
||||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
iconLeft={
|
||||||
}
|
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||||
>
|
}
|
||||||
{t`Import`}
|
>
|
||||||
</Button>
|
{t`Import`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -67,7 +71,7 @@ export default function BoardsPage() {
|
|||||||
modalSize="sm"
|
modalSize="sm"
|
||||||
isVisible={isOpen && modalContentType === "NEW_BOARD"}
|
isVisible={isOpen && modalContentType === "NEW_BOARD"}
|
||||||
>
|
>
|
||||||
<NewBoardForm />
|
<NewBoardForm isTemplate={!!isTemplate} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -86,7 +90,7 @@ export default function BoardsPage() {
|
|||||||
</>
|
</>
|
||||||
|
|
||||||
<div className="flex h-full flex-row">
|
<div className="flex h-full flex-row">
|
||||||
<BoardsList />
|
<BoardsList isTemplate={!!isTemplate} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -14,51 +14,6 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
|||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertUserInWorkspace } from "../utils/auth";
|
||||||
|
|
||||||
export const boardRouter = createTRPCRouter({
|
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
|
all: protectedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
openapi: {
|
openapi: {
|
||||||
@@ -70,7 +25,12 @@ export const boardRouter = createTRPCRouter({
|
|||||||
protect: true,
|
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(
|
.output(
|
||||||
z.custom<Awaited<ReturnType<typeof boardRepo.getAllByWorkspaceId>>>(),
|
z.custom<Awaited<ReturnType<typeof boardRepo.getAllByWorkspaceId>>>(),
|
||||||
)
|
)
|
||||||
@@ -96,7 +56,9 @@ export const boardRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
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;
|
return result;
|
||||||
}),
|
}),
|
||||||
@@ -116,6 +78,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
boardPublicId: z.string().min(12),
|
boardPublicId: z.string().min(12),
|
||||||
members: z.array(z.string().min(12)).optional(),
|
members: z.array(z.string().min(12)).optional(),
|
||||||
labels: 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>>>())
|
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getByPublicId>>>())
|
||||||
@@ -147,6 +110,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
{
|
{
|
||||||
members: input.members ?? [],
|
members: input.members ?? [],
|
||||||
labels: input.labels ?? [],
|
labels: input.labels ?? [],
|
||||||
|
type: input.type,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -222,6 +186,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
workspacePublicId: z.string().min(12),
|
workspacePublicId: z.string().min(12),
|
||||||
lists: z.array(z.string().min(1)),
|
lists: z.array(z.string().min(1)),
|
||||||
labels: 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>>>())
|
.output(z.custom<Awaited<ReturnType<typeof boardRepo.create>>>())
|
||||||
@@ -262,6 +227,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
|
type: input.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result)
|
if (!result)
|
||||||
|
|||||||
@@ -15,13 +15,21 @@ import {
|
|||||||
} from "@kan/db/schema";
|
} from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
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({
|
return db.query.boards.findMany({
|
||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
publicId: true,
|
||||||
name: 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: {
|
filters: {
|
||||||
members: string[];
|
members: string[];
|
||||||
labels: string[];
|
labels: string[];
|
||||||
|
type: "regular" | "template" | undefined;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
let cardIds: string[] = [];
|
let cardIds: string[] = [];
|
||||||
@@ -199,7 +208,11 @@ export const getByPublicId = async (
|
|||||||
orderBy: [asc(lists.index)],
|
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;
|
if (!board) return null;
|
||||||
@@ -412,6 +425,8 @@ export const create = async (
|
|||||||
workspaceId: number;
|
workspaceId: number;
|
||||||
importId?: number;
|
importId?: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
type?: "regular" | "template";
|
||||||
|
sourceBoardId?: number;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
@@ -423,6 +438,8 @@ export const create = async (
|
|||||||
workspaceId: boardInput.workspaceId,
|
workspaceId: boardInput.workspaceId,
|
||||||
importId: boardInput.importId,
|
importId: boardInput.importId,
|
||||||
slug: boardInput.slug,
|
slug: boardInput.slug,
|
||||||
|
type: boardInput.type ?? "regular",
|
||||||
|
sourceBoardId: boardInput.sourceBoardId,
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
id: boards.id,
|
id: boards.id,
|
||||||
@@ -542,3 +559,186 @@ export const isBoardSlugAvailable = async (
|
|||||||
|
|
||||||
return result === undefined;
|
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;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user