Compare commits
12 Commits
fix/api-ke
...
feat/kan-9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2edef84ea7 | ||
|
|
b651158494 | ||
|
|
a41fe6ea3d | ||
|
|
8ba991a0d3 | ||
|
|
a5757b206a | ||
|
|
e2e489114b | ||
|
|
17f406451e | ||
|
|
4220b2bff9 | ||
|
|
18e85ef6be | ||
|
|
734e0bbfc5 | ||
|
|
840c103e20 | ||
|
|
036fe25e55 |
@@ -27,7 +27,7 @@
|
||||
- 🔍 **Labels & Filters**: Organise and find cards quickly
|
||||
- 💬 **Comments**: Discuss and collaborate with your team
|
||||
- 📝 **Activity Log**: Track all card changes with detailed activity history
|
||||
- 🎨 **Templates (coming soon)** : Save time with reusable board templates
|
||||
- 🎨 **Templates** : Save time with reusable custom board templates
|
||||
- ⚡️ **Integrations (coming soon)** : Connect your favourite tools
|
||||
|
||||
See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||
|
||||
@@ -3,8 +3,10 @@ checksums:
|
||||
8033ed03a24d7c4898c90a5365297c20:
|
||||
"%20(edited)/singular": 666ee7e6d86957a87c23fa12b21b773f
|
||||
"%2C%20or%20see%20our/singular": e8a8f2a078fcaea03481710dcaccce5d
|
||||
"%7B0%7D/singular": 18782768c8a0b3c2078747b34fbbcd24
|
||||
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
|
||||
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
|
||||
"%7B0%7D%20not%20found/singular": a2665fc66f128fee1a80ffec61c9f1a1
|
||||
"%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef
|
||||
"%2410%2Fmonth/singular": cab5e752ed83096e34fca780d90e1fb9
|
||||
"%248%2Fmonth/singular": 4667034934bb2bc3d569c70b94205b51
|
||||
@@ -48,6 +50,7 @@ checksums:
|
||||
Approval/singular: 2f5426445800ecd5a3102aac940d459c
|
||||
Are%20you%20sure%20want%20to%20remove%20%7BentityLabel%7D%3F/singular: f9b67026c5ef86ac4d5944380d571961
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20the%20workspace%20%7B0%7D%3F/singular: 27ba2854202dd77c51e78c64e5c93946
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20%7B0%7D%3F/singular: e980ce9469e329ddfed14c7da2cb5e52
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20card%3F/singular: 131a5a7cccca7be8b32c987d9fa0b0c0
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20checklist%3F/singular: 7c0f540603474b1a30462dda087fd2f0
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20comment%3F/singular: 55fe6f1b52b71b91b2a079cfda3cc7fe
|
||||
@@ -74,7 +77,6 @@ checksums:
|
||||
Board%20visibility/singular: b4205f3058297833a626156057c24b26
|
||||
Board%20visibility%20updated/singular: 8e2f5d622fa9c94481e7c1629a154ecf
|
||||
Boards/singular: 8fff51c5872e051beb9b718336665e80
|
||||
Boards%20%7C%20%7B0%7D/singular: 70c349df806f4b2d8bd28136a45496f0
|
||||
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
||||
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
||||
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
|
||||
@@ -107,18 +109,19 @@ checksums:
|
||||
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
|
||||
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
|
||||
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
|
||||
Create%20%7B0%7D/singular: d37c29be6ccc0eb6062237f8508c3178
|
||||
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
|
||||
Create%20API%20key/singular: 70ed8431c6ed5f7fdef122cc34f75b41
|
||||
Create%20board/singular: 155b62818bfab0e34f0089e1b34a32f3
|
||||
Create%20card/singular: 32792935dd5837a9433909b04021c22b
|
||||
Create%20checklist/singular: 5cbca15a7004558c4e6d381f83a34da2
|
||||
Create%20invite%20link/singular: bef23fe786978f7abf78dece49a35a26
|
||||
Create%20label/singular: c64e8180fa956f005edc1fd9b8e65abb
|
||||
Create%20list/singular: 858c3d514aa95765ec82114393199144
|
||||
Create%20new%20board/singular: cbb87b1f4cc46b336ad9510c9f19407c
|
||||
Create%20new%20%7B0%7D/singular: 163dbd5cf86d512fadd4c3eaf9709a11
|
||||
Create%20new%20key/singular: 236bdf75e42b42ec7fb25639bdc035af
|
||||
Create%20new%20label/singular: 6be30980847b577847baa01bb5f29711
|
||||
Create%20new%20list/singular: ce61ca8be7b3f6c26c32b684a42a2c26
|
||||
Create%20template/singular: 57bfe6b58c42958a90f67d605a69ecdc
|
||||
Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca
|
||||
created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d
|
||||
Critical/singular: eb327cd411b50aee954f8d1d215d003a
|
||||
@@ -136,6 +139,7 @@ checksums:
|
||||
Delete%20card/singular: 18811ccf76fbb7a2772d648f7c5f0ea7
|
||||
Delete%20comment/singular: a3dae0c9ddbc73677aad9812e3dffd69
|
||||
Delete%20list/singular: b694f58e4c973d8797a78e746b550b1f
|
||||
Delete%20template/singular: bdf3e8342f581d498b7238cdd4897a4b
|
||||
Delete%20workspace/singular: 9017c821209528d9b20d40a488854cdf
|
||||
deleted%20a%20checklist/singular: c54f922dc6c238c9141c700cb3180baf
|
||||
deleted%20a%20checklist%20item/singular: 2b7a861600102ac5c271dd25c1df261a
|
||||
@@ -190,6 +194,7 @@ checksums:
|
||||
Execution/singular: cbac4a3c721123cbc6a883560bf29800
|
||||
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
|
||||
Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7
|
||||
Failed%20to%20create%20board/singular: a746e2afe881c3bf0a8e82a931ca3495
|
||||
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
|
||||
FAQs/singular: dc36d7992372ccd419b39ef51cf5b16c
|
||||
Feature/singular: 58f5f3f37862b6312a2f20ec1a1fd0e8
|
||||
@@ -207,7 +212,7 @@ checksums:
|
||||
Fun/singular: 395bdc48e943762b6cde649f4a96771d
|
||||
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
|
||||
Get%20Started/singular: 1d5f030c4ec9c869e647ae060518b948
|
||||
Get%20started%20by%20creating%20a%20new%20board/singular: 8fe63ba47e12bbf950f3eb33d1008993
|
||||
Get%20started%20by%20creating%20a%20new%20%7B0%7D/singular: 4ca5ef637c08104e9dbb5ff9f49f0680
|
||||
Get%20started%20by%20creating%20a%20new%20list/singular: bd6bf0806c95788a15ab438e2b09f885
|
||||
Get%20started%20for%20free%20today/singular: 41e129eba0f6411337b54a7a08ea1709
|
||||
Get%20started%20for%20free%2C%20with%20no%20usage%20limits.%20For%20collaboration%2C%20upgrade%20to%20a%20plan%20that%20fits%20the%20size%20of%20your%20team./singular: ddea69fa5d7b4023724296e1287872b6
|
||||
@@ -272,6 +277,7 @@ checksums:
|
||||
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
||||
Low%20Priority/singular: 509b5cb7c1e8a185e0aa6d38c64c90e8
|
||||
magic%20link/singular: db9d17a5bd98bba8aa0d8068c68579f3
|
||||
Make%20template/singular: 7a1c63b75cea690b3fda6c3f203e31ff
|
||||
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
|
||||
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
|
||||
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
|
||||
@@ -284,8 +290,8 @@ checksums:
|
||||
Name/singular: 9368b5a047572b6051f334af5aa76819
|
||||
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
|
||||
New/singular: 126d036fae5fb6b629728ecb97e6195b
|
||||
New%20%7B0%7D/singular: 67b6a22a65e0956f2091d00e6966652b
|
||||
New%20API%20key/singular: db3088aedba6e4a99b46451c5b3d36ed
|
||||
New%20board/singular: 63f4e979e29a7fc2f5c09ff91fa75966
|
||||
New%20card/singular: a33f6219a756127f91c2523cfe845a19
|
||||
New%20checklist/singular: 58252b71e9693ae0f4d2d0b72108a569
|
||||
New%20import/singular: e8fddfe15dd170277df919e5d37efd47
|
||||
@@ -293,11 +299,12 @@ checksums:
|
||||
New%20list/singular: 146c2e642b11056a0b07ed65733e4178
|
||||
New%20password%20is%20required/singular: f88427a4cca058631199a4384cb5622e
|
||||
New%20password%20must%20be%20different%20from%20current%20password/singular: db33013fcc6c40995cf8a37deb3d5667
|
||||
New%20template/singular: 10dca64383235d1596a010e559b2e35a
|
||||
New%20Ticket/singular: 665ff16ec736cba20e8a1b3beb3baa09
|
||||
New%20workspace/singular: 8eecf05b4ce29aa880adfed2e11476af
|
||||
Newsletter/singular: dcfb9666301924431ac8ae88e5b6f9d7
|
||||
Next%20Steps/singular: 12392fe73ae60b6e5a9d851256b8477e
|
||||
No%20boards/singular: e4397619e69b105e4317dda4560ff4a6
|
||||
No%20%7B0%7D/singular: a7a95e1dfdb4396da9e2be0e27f42323
|
||||
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
|
||||
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
|
||||
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
||||
@@ -395,6 +402,11 @@ checksums:
|
||||
System/singular: 803281c327a02a2feba182f4a2b38311
|
||||
Team%20Plan/singular: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
||||
Teams/singular: b63448c05270497973ac4407047dae02
|
||||
Template/singular: 87c2704ff2033e3c76bc97a45ae57d5c
|
||||
Template%20created/singular: 2c7a515a5d8a9afb26de6365cdaa0c92
|
||||
Template%20created%20successfully/singular: 2750600da57c5cd3c3c072810c8c9604
|
||||
Template%20name%20cannot%20exceed%20100%20characters/singular: 14527c9246e558a085ceee2e8d4e6560
|
||||
Template%20name%20is%20required/singular: 34d674d59c414c2051ef5bc0a62e3c00
|
||||
Templates/singular: 8ed62f56c81fa96c0f3182acb331bf63
|
||||
Terms%20of%20service/singular: 0b8a8b23aff021a6cde79613fdb39c28
|
||||
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
||||
@@ -426,6 +438,7 @@ checksums:
|
||||
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
||||
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
||||
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
||||
Unable%20to%20create%20template/singular: 7495f1434c9188849bffd015811d5337
|
||||
Unable%20to%20create%20workspace/singular: c1c949477e121d73ed6683eddf9cd874
|
||||
Unable%20to%20delete%20card/singular: 50ff06aa1265fe464f776aba33f1bef9
|
||||
Unable%20to%20delete%20checklist/singular: d7067e2c009f4919f0cca77df3c228ee
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
@@ -15,10 +16,11 @@ import membersIconDark from "~/assets/members-dark.json";
|
||||
import membersIconLight from "~/assets/members-light.json";
|
||||
import settingsIconDark from "~/assets/settings-dark.json";
|
||||
import settingsIconLight from "~/assets/settings-light.json";
|
||||
import templatesIconDark from "~/assets/templates-dark.json";
|
||||
import templatesIconLight from "~/assets/templates-light.json";
|
||||
import ReactiveButton from "~/components/ReactiveButton";
|
||||
import UserMenu from "~/components/UserMenu";
|
||||
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
interface SideNavigationProps {
|
||||
user: UserType;
|
||||
@@ -69,6 +71,11 @@ export default function SideNavigation({
|
||||
href: "/boards",
|
||||
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
||||
},
|
||||
{
|
||||
name: t`Templates`,
|
||||
href: "/templates",
|
||||
icon: isDarkMode ? templatesIconDark : templatesIconLight,
|
||||
},
|
||||
{
|
||||
name: t`Members`,
|
||||
href: "/members",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import Popup from "~/components/Popup";
|
||||
import CardView, { CardRightPanel } from "~/views/card";
|
||||
|
||||
const CardPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<>
|
||||
<CardView isTemplate />
|
||||
<Popup />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CardPage.getLayout = (page) =>
|
||||
getDashboardLayout(page, <CardRightPanel isTemplate />, true);
|
||||
|
||||
export default CardPage;
|
||||
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;
|
||||
17
apps/web/src/pages/templates/index.tsx
Normal file
17
apps/web/src/pages/templates/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 BoardsView from "~/views/boards";
|
||||
|
||||
const TemplatesPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<>
|
||||
<BoardsView isTemplate />
|
||||
<Popup />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
TemplatesPage.getLayout = (page) => getDashboardLayout(page);
|
||||
|
||||
export default TemplatesPage;
|
||||
@@ -1,23 +1,51 @@
|
||||
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();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
disabled={isLoading}
|
||||
items={[
|
||||
...(isTemplate
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: () => openModal("CREATE_TEMPLATE"),
|
||||
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" />,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
138
apps/web/src/views/board/components/NewTemplateForm.tsx
Normal file
138
apps/web/src/views/board/components/NewTemplateForm.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const schema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: t`Template name is required` })
|
||||
.max(100, { message: t`Template name cannot exceed 100 characters` }),
|
||||
workspacePublicId: z.string(),
|
||||
sourceBoardPublicId: z.string(),
|
||||
});
|
||||
|
||||
interface NewBoardInputWithTemplate {
|
||||
name: string;
|
||||
workspacePublicId: string;
|
||||
sourceBoardPublicId: string;
|
||||
}
|
||||
|
||||
export function NewTemplateForm({
|
||||
sourceBoardPublicId,
|
||||
workspacePublicId,
|
||||
sourceBoardName,
|
||||
}: {
|
||||
sourceBoardPublicId: string;
|
||||
workspacePublicId: string;
|
||||
sourceBoardName: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<NewBoardInputWithTemplate>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: sourceBoardName,
|
||||
workspacePublicId,
|
||||
sourceBoardPublicId,
|
||||
},
|
||||
});
|
||||
|
||||
const createBoard = api.board.create.useMutation({
|
||||
onSuccess: (newTemplate) => {
|
||||
if (!newTemplate) {
|
||||
showPopup({
|
||||
header: t`Unable to create template`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
} else {
|
||||
router.push(`/templates/${newTemplate.publicId}`);
|
||||
showPopup({
|
||||
header: t`Template created`,
|
||||
message: t`Template created successfully`,
|
||||
icon: "success",
|
||||
});
|
||||
}
|
||||
closeModal();
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to create template`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: NewBoardInputWithTemplate) => {
|
||||
createBoard.mutate({
|
||||
name: data.name,
|
||||
workspacePublicId: data.workspacePublicId,
|
||||
sourceBoardPublicId: data.sourceBoardPublicId,
|
||||
lists: [],
|
||||
labels: [],
|
||||
type: "template",
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const titleElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#name");
|
||||
if (titleElement) titleElement.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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 template`}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder={t`Name`}
|
||||
{...register("name", { required: true })}
|
||||
errorMessage={errors.name?.message}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<Button type="submit" isLoading={createBoard.isPending}>
|
||||
{t`Create template`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,11 @@ import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DragDropContext, Draggable } from "react-beautiful-dnd";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiOutlinePlusSmall, HiOutlineSquare3Stack3D } from "react-icons/hi2";
|
||||
import {
|
||||
HiOutlinePlusSmall,
|
||||
HiOutlineRectangleStack,
|
||||
HiOutlineSquare3Stack3D,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import type { UpdateBoardInput } from "@kan/api/types";
|
||||
|
||||
@@ -32,14 +36,15 @@ import Filters from "./components/Filters";
|
||||
import List from "./components/List";
|
||||
import { NewCardForm } from "./components/NewCardForm";
|
||||
import { NewListForm } from "./components/NewListForm";
|
||||
import { NewTemplateForm } from "./components/NewTemplateForm";
|
||||
import UpdateBoardSlugButton from "./components/UpdateBoardSlugButton";
|
||||
import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm";
|
||||
import VisibilityButton from "./components/VisibilityButton";
|
||||
|
||||
type PublicListId = string;
|
||||
|
||||
export default function BoardPage() {
|
||||
const params = useParams() as { boardId: string[] } | null;
|
||||
export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const params = useParams() as { boardId: string | string[] } | null;
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
@@ -49,7 +54,11 @@ export default function BoardPage() {
|
||||
useState<PublicListId>("");
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
const boardId = params?.boardId.length ? params.boardId[0] : null;
|
||||
const boardId = params?.boardId
|
||||
? Array.isArray(params.boardId)
|
||||
? params.boardId[0]
|
||||
: params.boardId
|
||||
: null;
|
||||
|
||||
const updateBoard = api.board.update.useMutation();
|
||||
|
||||
@@ -67,10 +76,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 +223,7 @@ export default function BoardPage() {
|
||||
};
|
||||
|
||||
const onDragEnd = ({
|
||||
source,
|
||||
source: _source,
|
||||
destination,
|
||||
draggableId,
|
||||
type,
|
||||
@@ -241,7 +256,10 @@ export default function BoardPage() {
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_BOARD"}
|
||||
>
|
||||
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
|
||||
<DeleteBoardConfirmation
|
||||
isTemplate={!!isTemplate}
|
||||
boardPublicId={boardId ?? ""}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
@@ -259,6 +277,7 @@ export default function BoardPage() {
|
||||
isVisible={isOpen && modalContentType === "NEW_CARD"}
|
||||
>
|
||||
<NewCardForm
|
||||
isTemplate={!!isTemplate}
|
||||
boardPublicId={boardId ?? ""}
|
||||
listPublicId={selectedPublicListId}
|
||||
queryParams={queryParams}
|
||||
@@ -321,6 +340,17 @@ export default function BoardPage() {
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CREATE_TEMPLATE"}
|
||||
>
|
||||
<NewTemplateForm
|
||||
workspacePublicId={workspace.publicId ?? ""}
|
||||
sourceBoardPublicId={boardId ?? ""}
|
||||
sourceBoardName={boardData?.name ?? ""}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -328,7 +358,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 +384,46 @@ 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 && (
|
||||
<div className="inline-flex cursor-default items-center justify-center whitespace-nowrap rounded-md border-[1px] border-light-300 bg-light-50 px-3 py-2 text-sm font-semibold text-light-950 shadow-sm dark:border-dark-300 dark:bg-dark-50 dark:text-dark-950">
|
||||
<span className="mr-2">
|
||||
<HiOutlineRectangleStack />
|
||||
</span>
|
||||
{t`Template`}
|
||||
</div>
|
||||
)}
|
||||
{!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 +438,12 @@ export default function BoardPage() {
|
||||
>
|
||||
{t`New list`}
|
||||
</Button>
|
||||
<BoardDropdown isLoading={!boardData} />
|
||||
<BoardDropdown
|
||||
isTemplate={!!isTemplate}
|
||||
isLoading={!boardData}
|
||||
boardPublicId={boardId ?? ""}
|
||||
workspacePublicId={workspace.publicId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -475,7 +525,11 @@ export default function BoardPage() {
|
||||
e.preventDefault();
|
||||
}}
|
||||
key={card.publicId}
|
||||
href={`/cards/${card.publicId}`}
|
||||
href={
|
||||
isTemplate
|
||||
? `/templates/${boardId}/cards/${card.publicId}`
|
||||
: `/cards/${card.publicId}`
|
||||
}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||
card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HiCheckCircle } from "react-icons/hi2";
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
sourceBoardPublicId?: string;
|
||||
name: string;
|
||||
lists: string[];
|
||||
labels: string[];
|
||||
@@ -88,16 +89,18 @@ export default function TemplateBoards({
|
||||
currentBoard,
|
||||
setCurrentBoard,
|
||||
showTemplates,
|
||||
customTemplates,
|
||||
}: {
|
||||
currentBoard: Template | null;
|
||||
setCurrentBoard: (board: Template | null) => void;
|
||||
showTemplates: boolean;
|
||||
customTemplates: Template[] | null;
|
||||
}) {
|
||||
const [showFade, setShowFade] = useState(false);
|
||||
const [showTopFade, setShowTopFade] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const templates = getTemplates();
|
||||
const templates = [...(customTemplates ?? []), ...getTemplates()];
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -37,7 +37,7 @@ interface FormValues {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function CardRightPanel() {
|
||||
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const router = useRouter();
|
||||
const cardId = Array.isArray(router.query.cardId)
|
||||
? router.query.cardId[0]
|
||||
@@ -121,19 +121,21 @@ export function CardRightPanel() {
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
||||
<MemberSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
members={formattedMembers}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
{!isTemplate && (
|
||||
<div className="flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
||||
<MemberSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
members={formattedMembers}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CardPage() {
|
||||
export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const {
|
||||
@@ -228,7 +230,7 @@ export default function CardPage() {
|
||||
<>
|
||||
<Link
|
||||
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
|
||||
href={`/boards/${board?.publicId}`}
|
||||
href={`${isTemplate ? "/templates" : "/boards"}/${board?.publicId}`}
|
||||
>
|
||||
{board?.name}
|
||||
</Link>
|
||||
@@ -296,9 +298,11 @@ export default function CardPage() {
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
</div>
|
||||
{!isTemplate && (
|
||||
<div className="mt-6">
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,7 @@ const FeatureItem = ({
|
||||
description: string;
|
||||
icon: Record<string, unknown>;
|
||||
comingSoon?: boolean;
|
||||
new?: boolean;
|
||||
};
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -66,6 +67,12 @@ const FeatureItem = ({
|
||||
{t`Coming soon`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feature.new && (
|
||||
<div className="absolute right-4 top-4 rounded-full border border-light-300 px-2 py-1 text-[10px] text-light-1000 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900">
|
||||
{t`New`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -108,7 +115,7 @@ const Features = ({ theme }: { theme: "light" | "dark" }) => {
|
||||
title: t`Templates`,
|
||||
description: t`Save time with reusable board templates.`,
|
||||
icon: isDark ? templatesIconDark : templatesIconLight,
|
||||
comingSoon: true,
|
||||
new: true,
|
||||
},
|
||||
{
|
||||
title: t`Integrations`,
|
||||
|
||||
@@ -25,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>>>(),
|
||||
)
|
||||
@@ -51,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;
|
||||
}),
|
||||
@@ -71,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>>>())
|
||||
@@ -102,6 +110,7 @@ export const boardRouter = createTRPCRouter({
|
||||
{
|
||||
members: input.members ?? [],
|
||||
labels: input.labels ?? [],
|
||||
type: input.type,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -177,6 +186,8 @@ 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(),
|
||||
sourceBoardPublicId: z.string().min(12).optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.create>>>())
|
||||
@@ -202,6 +213,79 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||
|
||||
// If sourceBoardPublicId is provided, clone the source board
|
||||
if (input.sourceBoardPublicId) {
|
||||
// First get the source board info (ID and type)
|
||||
const sourceBoardInfo = await boardRepo.getIdByPublicId(
|
||||
ctx.db,
|
||||
input.sourceBoardPublicId,
|
||||
);
|
||||
|
||||
if (!sourceBoardInfo)
|
||||
throw new TRPCError({
|
||||
message: `Source board with public ID ${input.sourceBoardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
// Get the full board data with the correct type
|
||||
const sourceBoard = await boardRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.sourceBoardPublicId,
|
||||
{
|
||||
members: [],
|
||||
labels: [],
|
||||
type: sourceBoardInfo.type,
|
||||
},
|
||||
);
|
||||
|
||||
if (!sourceBoard)
|
||||
throw new TRPCError({
|
||||
message: `Source board with public ID ${input.sourceBoardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
// Verify the source board belongs to the same workspace
|
||||
const sourceWorkspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
sourceBoard.workspace.publicId,
|
||||
);
|
||||
|
||||
if (!sourceWorkspace || sourceWorkspace.id !== workspace.id)
|
||||
throw new TRPCError({
|
||||
message: `Source board does not belong to this workspace`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
|
||||
let slug = generateSlug(input.name);
|
||||
|
||||
const isSlugUnique = await boardRepo.isSlugUnique(ctx.db, {
|
||||
slug,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (!isSlugUnique || input.type === "template")
|
||||
slug = `${slug}-${generateUID()}`;
|
||||
|
||||
const result = await boardRepo.createFromSnapshot(ctx.db, {
|
||||
source: sourceBoard,
|
||||
workspaceId: workspace.id,
|
||||
createdBy: userId,
|
||||
slug,
|
||||
name: input.name,
|
||||
type: input.type ?? "regular",
|
||||
sourceBoardId: sourceBoardInfo.id,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Failed to create board from source`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Otherwise, create a new board with provided lists and labels
|
||||
let slug = generateSlug(input.name);
|
||||
|
||||
const isSlugUnique = await boardRepo.isSlugUnique(ctx.db, {
|
||||
@@ -209,7 +293,8 @@ export const boardRouter = createTRPCRouter({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (!isSlugUnique) slug = `${slug}-${generateUID()}`;
|
||||
if (!isSlugUnique || input.type === "template")
|
||||
slug = `${slug}-${generateUID()}`;
|
||||
|
||||
const result = await boardRepo.create(ctx.db, {
|
||||
publicId: generateUID(),
|
||||
@@ -217,6 +302,7 @@ export const boardRouter = createTRPCRouter({
|
||||
name: input.name,
|
||||
createdBy: userId,
|
||||
workspaceId: workspace.id,
|
||||
type: input.type,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TYPE "public"."board_type" AS ENUM('regular', 'template');--> statement-breakpoint
|
||||
ALTER TABLE "board" ADD COLUMN "type" "board_type" DEFAULT 'regular' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "board" ADD COLUMN "sourceBoardId" bigint;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "board_type_idx" ON "board" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "board_source_idx" ON "board" USING btree ("sourceBoardId");
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "card_activity" ADD COLUMN "sourceBoardId" bigint;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_sourceBoardId_board_id_fk" FOREIGN KEY ("sourceBoardId") REFERENCES "public"."board"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
2818
packages/db/migrations/meta/20251007204129_snapshot.json
Normal file
2818
packages/db/migrations/meta/20251007204129_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2837
packages/db/migrations/meta/20251009211316_snapshot.json
Normal file
2837
packages/db/migrations/meta/20251009211316_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,20 @@
|
||||
"when": 1759356096392,
|
||||
"tag": "20251001220136_AddFuzzySearchSupport",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1759869689304,
|
||||
"tag": "20251007204129_AddBoardTypeAndSourceIdColumns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1760044396109,
|
||||
"tag": "20251009211316_AddBoardSourceIdToCardActivity",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -25,6 +25,10 @@ export const boardVisibilityEnum = pgEnum(
|
||||
boardVisibilityStatuses,
|
||||
);
|
||||
|
||||
export const boardTypes = ["regular", "template"] as const;
|
||||
export type BoardType = (typeof boardTypes)[number];
|
||||
export const boardTypeEnum = pgEnum("board_type", boardTypes);
|
||||
|
||||
export const boards = pgTable(
|
||||
"board",
|
||||
{
|
||||
@@ -49,9 +53,13 @@ export const boards = pgTable(
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
visibility: boardVisibilityEnum("visibility").notNull().default("private"),
|
||||
type: boardTypeEnum("type").notNull().default("regular"),
|
||||
sourceBoardId: bigint("sourceBoardId", { mode: "number" }),
|
||||
},
|
||||
(table) => [
|
||||
index("board_visibility_idx").on(table.visibility),
|
||||
index("board_type_idx").on(table.type),
|
||||
index("board_source_idx").on(table.sourceBoardId),
|
||||
uniqueIndex("unique_slug_per_workspace")
|
||||
.on(table.workspaceId, table.slug)
|
||||
.where(sql`${table.deletedAt} IS NULL`),
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { boards } from "./boards";
|
||||
import { checklists } from "./checklists";
|
||||
import { imports } from "./imports";
|
||||
import { labels } from "./labels";
|
||||
@@ -133,6 +134,10 @@ export const cardActivities = pgTable("card_activity", {
|
||||
),
|
||||
fromComment: text("fromComment"),
|
||||
toComment: text("toComment"),
|
||||
sourceBoardId: bigint("sourceBoardId", { mode: "number" }).references(
|
||||
() => boards.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
}).enableRLS();
|
||||
|
||||
export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
|
||||
|
||||
Reference in New Issue
Block a user