feat: add create new checklist modal
This commit is contained in:
58
apps/web/src/components/CircularProgress.tsx
Normal file
58
apps/web/src/components/CircularProgress.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
interface CircularProgressProps {
|
||||||
|
progress: number; // 0-100
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CircularProgress = ({
|
||||||
|
progress,
|
||||||
|
size = "md",
|
||||||
|
className,
|
||||||
|
}: CircularProgressProps) => {
|
||||||
|
const radius = 40;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const strokeDashoffset = circumference - (progress / 100) * circumference;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={twMerge("relative", className)}>
|
||||||
|
<svg
|
||||||
|
className={twMerge(
|
||||||
|
"-rotate-90 transform",
|
||||||
|
size === "sm" && "h-4 w-4",
|
||||||
|
size === "md" && "h-5 w-5",
|
||||||
|
size === "lg" && "h-8 w-8",
|
||||||
|
)}
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
className="text-light-300 dark:text-dark-300"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className={twMerge(
|
||||||
|
"transition-all duration-300 ease-in-out",
|
||||||
|
progress === 100 ? "text-green-500" : "text-blue-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CircularProgress;
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { HiEllipsisHorizontal, HiOutlineTrash } from "react-icons/hi2";
|
import {
|
||||||
|
HiEllipsisHorizontal,
|
||||||
|
HiOutlineCheckCircle,
|
||||||
|
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";
|
||||||
@@ -10,6 +14,13 @@ export default function BoardDropdown() {
|
|||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={[
|
items={[
|
||||||
|
{
|
||||||
|
label: t`Add checklist`,
|
||||||
|
action: () => openModal("ADD_CHECKLIST"),
|
||||||
|
icon: (
|
||||||
|
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t`Delete card`,
|
label: t`Delete card`,
|
||||||
action: () => openModal("DELETE_CARD"),
|
action: () => openModal("DELETE_CARD"),
|
||||||
|
|||||||
126
apps/web/src/views/card/components/NewChecklistForm.tsx
Normal file
126
apps/web/src/views/card/components/NewChecklistForm.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { HiXMark } from "react-icons/hi2";
|
||||||
|
|
||||||
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface NewChecklistFormInput {
|
||||||
|
name: string;
|
||||||
|
cardPublicId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewChecklistForm({ cardPublicId }: { cardPublicId: string }) {
|
||||||
|
const { closeModal } = useModal();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const { register, handleSubmit, reset, setValue, watch } =
|
||||||
|
useForm<NewChecklistFormInput>({
|
||||||
|
defaultValues: {
|
||||||
|
name: "Checklist",
|
||||||
|
cardPublicId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createChecklist = api.checklist.create.useMutation({
|
||||||
|
onMutate: async (args) => {
|
||||||
|
// await utils.board.byId.cancel();
|
||||||
|
// const currentState = utils.board.byId.getData(queryParams);
|
||||||
|
// utils.board.byId.setData(queryParams, (oldBoard) => {
|
||||||
|
// if (!oldBoard) return oldBoard;
|
||||||
|
// const newList = {
|
||||||
|
// publicId: generateUID(),
|
||||||
|
// name: args.name,
|
||||||
|
// boardId: 1,
|
||||||
|
// boardPublicId,
|
||||||
|
// cards: [],
|
||||||
|
// index: oldBoard.lists.length,
|
||||||
|
// };
|
||||||
|
// const updatedLists = [...oldBoard.lists, newList];
|
||||||
|
// return { ...oldBoard, lists: updatedLists };
|
||||||
|
// });
|
||||||
|
// return { previousState: currentState };
|
||||||
|
},
|
||||||
|
onError: (_error, _newList, context) => {
|
||||||
|
// utils.board.byId.setData(queryParams, context?.previousState);
|
||||||
|
showPopup({
|
||||||
|
header: t`Unable to create checklist`,
|
||||||
|
message: t`Please try again later, or contact customer support.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// onSettled: async () => {
|
||||||
|
// await utils.board.byId.invalidate(queryParams);
|
||||||
|
// },
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nameElement: HTMLElement | null =
|
||||||
|
document.querySelector<HTMLElement>("#checklist-name");
|
||||||
|
if (nameElement) nameElement.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSubmit = (data: NewChecklistFormInput) => {
|
||||||
|
closeModal();
|
||||||
|
reset({
|
||||||
|
name: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
createChecklist.mutate({
|
||||||
|
name: data.name,
|
||||||
|
cardPublicId: data.cardPublicId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="px-5 pt-5">
|
||||||
|
<div className="flex w-full items-center justify-between pb-4">
|
||||||
|
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||||
|
{t`New checklist`}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
closeModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="checklist-name"
|
||||||
|
placeholder={t`Checklist name`}
|
||||||
|
{...register("name")}
|
||||||
|
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"
|
||||||
|
disabled={createChecklist.isPending || !watch("name")}
|
||||||
|
>
|
||||||
|
{t`Create checklist`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import { IoChevronForwardSharp } from "react-icons/io5";
|
import { IoChevronForwardSharp } from "react-icons/io5";
|
||||||
|
|
||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
|
import CircularProgress from "~/components/CircularProgress";
|
||||||
import Editor from "~/components/Editor";
|
import Editor from "~/components/Editor";
|
||||||
import FeedbackModal from "~/components/FeedbackModal";
|
import FeedbackModal from "~/components/FeedbackModal";
|
||||||
import { LabelForm } from "~/components/LabelForm";
|
import { LabelForm } from "~/components/LabelForm";
|
||||||
@@ -25,6 +26,7 @@ import Dropdown from "./components/Dropdown";
|
|||||||
import LabelSelector from "./components/LabelSelector";
|
import LabelSelector from "./components/LabelSelector";
|
||||||
import ListSelector from "./components/ListSelector";
|
import ListSelector from "./components/ListSelector";
|
||||||
import MemberSelector from "./components/MemberSelector";
|
import MemberSelector from "./components/MemberSelector";
|
||||||
|
import { NewChecklistForm } from "./components/NewChecklistForm";
|
||||||
import NewCommentForm from "./components/NewCommentForm";
|
import NewCommentForm from "./components/NewCommentForm";
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
@@ -251,6 +253,39 @@ export default function CardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{card.checklists.length > 0 && (
|
||||||
|
<div className="border-light-300 pb-4 dark:border-dark-300">
|
||||||
|
<div>
|
||||||
|
{card.checklists.map((checklist) => {
|
||||||
|
const completedItems = checklist.items.filter(
|
||||||
|
(item) => item.completed,
|
||||||
|
);
|
||||||
|
const progress =
|
||||||
|
checklist.items.length > 0
|
||||||
|
? (completedItems.length / checklist.items.length) *
|
||||||
|
100
|
||||||
|
: 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="text-md flex items-center gap-3 font-medium text-light-900 dark:text-dark-1000"
|
||||||
|
key={checklist.publicId}
|
||||||
|
>
|
||||||
|
<span>{checklist.name}</span>
|
||||||
|
<CircularProgress
|
||||||
|
progress={progress}
|
||||||
|
size="md"
|
||||||
|
className="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||||
|
{completedItems.length}/{checklist.items.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
||||||
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
||||||
{t`Activity`}
|
{t`Activity`}
|
||||||
@@ -303,6 +338,9 @@ export default function CardPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
|
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
|
||||||
|
{modalContentType === "ADD_CHECKLIST" && (
|
||||||
|
<NewChecklistForm cardPublicId={cardId} />
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { boardRouter } from "./routers/board";
|
import { boardRouter } from "./routers/board";
|
||||||
import { cardRouter } from "./routers/card";
|
import { cardRouter } from "./routers/card";
|
||||||
|
import { checklistRouter } from "./routers/checklist";
|
||||||
import { feedbackRouter } from "./routers/feedback";
|
import { feedbackRouter } from "./routers/feedback";
|
||||||
import { importRouter } from "./routers/import";
|
import { importRouter } from "./routers/import";
|
||||||
import { integrationRouter } from "./routers/integration";
|
import { integrationRouter } from "./routers/integration";
|
||||||
@@ -13,6 +14,7 @@ import { createTRPCRouter } from "./trpc";
|
|||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
board: boardRouter,
|
board: boardRouter,
|
||||||
card: cardRouter,
|
card: cardRouter,
|
||||||
|
checklist: checklistRouter,
|
||||||
feedback: feedbackRouter,
|
feedback: feedbackRouter,
|
||||||
label: labelRouter,
|
label: labelRouter,
|
||||||
list: listRouter,
|
list: listRouter,
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { createTRPCRouter, protectedProcedure } from "../trpc";
|
|||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertUserInWorkspace } from "../utils/auth";
|
||||||
|
|
||||||
const checklistSchema = z.object({
|
const checklistSchema = z.object({
|
||||||
publicId: z.string(),
|
publicId: z.string().length(12),
|
||||||
title: z.string(),
|
name: z.string().min(1).max(255),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const checklistRouter = createTRPCRouter({
|
export const checklistRouter = createTRPCRouter({
|
||||||
@@ -26,8 +26,8 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
cardPublicId: z.string().min(12),
|
cardPublicId: z.string().length(12),
|
||||||
title: z.string().min(1),
|
name: z.string().min(1).max(255),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.output(checklistSchema)
|
.output(checklistSchema)
|
||||||
@@ -54,7 +54,7 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||||
|
|
||||||
const newChecklist = await checklistRepo.create(ctx.db, {
|
const newChecklist = await checklistRepo.create(ctx.db, {
|
||||||
title: input.title,
|
name: input.name,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
cardId: card.id,
|
cardId: card.id,
|
||||||
});
|
});
|
||||||
@@ -17,7 +17,7 @@ ALTER TABLE "card_checklist_item" ENABLE ROW LEVEL SECURITY;--> statement-breakp
|
|||||||
CREATE TABLE IF NOT EXISTS "card_checklist" (
|
CREATE TABLE IF NOT EXISTS "card_checklist" (
|
||||||
"id" bigserial PRIMARY KEY NOT NULL,
|
"id" bigserial PRIMARY KEY NOT NULL,
|
||||||
"publicId" varchar(12) NOT NULL,
|
"publicId" varchar(12) NOT NULL,
|
||||||
"title" varchar(255) NOT NULL,
|
"name" varchar(255) NOT NULL,
|
||||||
"index" integer NOT NULL,
|
"index" integer NOT NULL,
|
||||||
"cardId" bigint NOT NULL,
|
"cardId" bigint NOT NULL,
|
||||||
"createdBy" uuid,
|
"createdBy" uuid,
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"id": "0b7ca39b-1114-4c0d-8e55-d932f40706ba",
|
"id": "957fcb16-856c-4756-ac3b-13f2a0814959",
|
||||||
"prevId": "91f9a2fa-31e2-4f3a-bdb9-852292fd7501",
|
"prevId": "91f9a2fa-31e2-4f3a-bdb9-852292fd7501",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
@@ -1344,8 +1344,8 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"title": {
|
"name": {
|
||||||
"name": "title",
|
"name": "name",
|
||||||
"type": "varchar(255)",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
@@ -54,9 +54,9 @@
|
|||||||
{
|
{
|
||||||
"idx": 7,
|
"idx": 7,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1752582887553,
|
"when": 1754511666265,
|
||||||
"tag": "20250715123447_AddCardChecklists",
|
"tag": "20250806202106_glossy_luminals",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
cards,
|
cards,
|
||||||
cardsToLabels,
|
cardsToLabels,
|
||||||
cardToWorkspaceMembers,
|
cardToWorkspaceMembers,
|
||||||
|
checklists,
|
||||||
labels,
|
labels,
|
||||||
lists,
|
lists,
|
||||||
workspaceMembers,
|
workspaceMembers,
|
||||||
@@ -300,6 +301,24 @@ export const getWithListAndMembersByPublicId = async (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
checklists: {
|
||||||
|
columns: {
|
||||||
|
publicId: true,
|
||||||
|
name: true,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
where: isNull(checklists.deletedAt),
|
||||||
|
with: {
|
||||||
|
items: {
|
||||||
|
columns: {
|
||||||
|
publicId: true,
|
||||||
|
title: true,
|
||||||
|
completed: true,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
list: {
|
list: {
|
||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
publicId: true,
|
||||||
|
|||||||
41
packages/db/src/repository/checklist.repo.ts
Normal file
41
packages/db/src/repository/checklist.repo.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||||
|
|
||||||
|
import type { dbClient } from "@kan/db/client";
|
||||||
|
import { checklists } from "@kan/db/schema";
|
||||||
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
|
export const create = async (
|
||||||
|
db: dbClient,
|
||||||
|
checklistInput: {
|
||||||
|
cardId: number;
|
||||||
|
name: string;
|
||||||
|
createdBy: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
const card = await tx.query.checklists.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(checklists.cardId, checklistInput.cardId),
|
||||||
|
isNull(checklists.deletedAt),
|
||||||
|
),
|
||||||
|
orderBy: desc(checklists.index),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [result] = await tx
|
||||||
|
.insert(checklists)
|
||||||
|
.values({
|
||||||
|
publicId: generateUID(),
|
||||||
|
name: checklistInput.name,
|
||||||
|
createdBy: checklistInput.createdBy,
|
||||||
|
cardId: checklistInput.cardId,
|
||||||
|
index: card ? card.index + 1 : 0,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: checklists.id,
|
||||||
|
publicId: checklists.publicId,
|
||||||
|
name: checklists.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -16,7 +16,7 @@ import { users } from "./users";
|
|||||||
export const checklists = pgTable("card_checklist", {
|
export const checklists = pgTable("card_checklist", {
|
||||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||||
title: varchar("title", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
index: integer("index").notNull(),
|
index: integer("index").notNull(),
|
||||||
cardId: bigint("cardId", { mode: "number" })
|
cardId: bigint("cardId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|||||||
Reference in New Issue
Block a user