feat: add create new checklist modal

This commit is contained in:
Henry
2025-08-06 22:22:28 +01:00
parent 1cf5808ce7
commit e43fa0c7b3
12 changed files with 309 additions and 14 deletions

View 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;

View File

@@ -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"),

View 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>
);
}

View File

@@ -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>
</> </>

View File

@@ -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,

View File

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

View File

@@ -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,

View File

@@ -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

View File

@@ -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
} }
] ]
} }

View File

@@ -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,

View 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;
});
};

View File

@@ -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()