feat: card checklists (#141)

* feat: setup checklist schema

* feat: scaffold checklist router

* feat: add create new checklist modal

* feat: create new checklist item

* feat: toggle checklist item completed state

* feat: update checklist name

* feat: delete checklist

* feat: show checklists progress on cards

* feat: tweak light mode styling

* feat: focus item form on creation of new checklist

* feat: tweak new checklist item form styling

* feat: add checklist activity

* feat: show checklist progress on public board page

* feat: display checklist items on public card modal

* chore: add translations
This commit is contained in:
Henry
2025-08-14 19:59:36 +01:00
committed by GitHub
parent 80d1c5f805
commit 13b10471ec
45 changed files with 8171 additions and 684 deletions

View File

@@ -7,6 +7,8 @@ import {
cards,
cardsToLabels,
cardToWorkspaceMembers,
checklistItems,
checklists,
labels,
lists,
workspaceMembers,
@@ -164,6 +166,27 @@ export const getByPublicId = async (
},
},
},
checklists: {
columns: {
publicId: true,
name: true,
index: true,
},
where: isNull(checklists.deletedAt),
orderBy: asc(checklists.index),
with: {
items: {
columns: {
publicId: true,
title: true,
completed: true,
index: true,
},
where: isNull(checklistItems.deletedAt),
orderBy: asc(checklistItems.index),
},
},
},
},
where: and(
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,
@@ -280,6 +303,27 @@ export const getBySlug = async (
},
},
},
checklists: {
columns: {
publicId: true,
name: true,
index: true,
},
where: isNull(checklists.deletedAt),
orderBy: asc(checklists.index),
with: {
items: {
columns: {
publicId: true,
title: true,
completed: true,
index: true,
},
where: isNull(checklistItems.deletedAt),
orderBy: asc(checklistItems.index),
},
},
},
},
where: and(
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,

View File

@@ -6,6 +6,8 @@ import {
cards,
cardsToLabels,
cardToWorkspaceMembers,
checklistItems,
checklists,
labels,
lists,
workspaceMembers,
@@ -300,6 +302,27 @@ export const getWithListAndMembersByPublicId = async (
},
},
},
checklists: {
columns: {
publicId: true,
name: true,
index: true,
},
where: isNull(checklists.deletedAt),
orderBy: asc(checklists.index),
with: {
items: {
columns: {
publicId: true,
title: true,
completed: true,
index: true,
},
where: isNull(checklistItems.deletedAt),
orderBy: asc(checklistItems.index),
},
},
},
list: {
columns: {
publicId: true,

View File

@@ -0,0 +1,216 @@
import { and, desc, eq, isNull } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import { checklistItems, 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;
});
};
export const createItem = async (
db: dbClient,
checklistItemInput: {
checklistId: number;
title: string;
createdBy: string;
},
) => {
return db.transaction(async (tx) => {
const lastItem = await tx.query.checklistItems.findFirst({
where: and(
eq(checklistItems.checklistId, checklistItemInput.checklistId),
isNull(checklistItems.deletedAt),
),
orderBy: desc(checklistItems.index),
});
const [result] = await tx
.insert(checklistItems)
.values({
publicId: generateUID(),
title: checklistItemInput.title,
createdBy: checklistItemInput.createdBy,
checklistId: checklistItemInput.checklistId,
index: lastItem ? lastItem.index + 1 : 0,
completed: false,
})
.returning({
id: checklistItems.id,
publicId: checklistItems.publicId,
title: checklistItems.title,
completed: checklistItems.completed,
});
return result;
});
};
export const getChecklistByPublicId = async (
db: dbClient,
checklistPublicId: string,
) => {
const checklist = await db.query.checklists.findFirst({
where: and(
eq(checklists.publicId, checklistPublicId),
isNull(checklists.deletedAt),
),
with: {
card: {
with: {
list: {
with: {
board: {
with: {
workspace: true,
},
},
},
},
},
},
},
});
return checklist;
};
export const getChecklistItemByPublicIdWithChecklist = async (
db: dbClient,
checklistItemPublicId: string,
) => {
const item = await db.query.checklistItems.findFirst({
where: and(
eq(checklistItems.publicId, checklistItemPublicId),
isNull(checklistItems.deletedAt),
),
with: {
checklist: {
with: {
card: {
with: {
list: {
with: {
board: {
with: { workspace: true },
},
},
},
},
},
},
},
},
});
return item;
};
export const updateItemById = async (
db: dbClient,
args: { id: number; title?: string; completed?: boolean },
) => {
const [result] = await db
.update(checklistItems)
.set({
...(args.title !== undefined ? { title: args.title } : {}),
...(args.completed !== undefined ? { completed: args.completed } : {}),
updatedAt: new Date(),
})
.where(eq(checklistItems.id, args.id))
.returning({
publicId: checklistItems.publicId,
title: checklistItems.title,
completed: checklistItems.completed,
});
return result;
};
export const softDeleteItemById = async (
db: dbClient,
args: { id: number; deletedAt: Date; deletedBy: string },
) => {
const [result] = await db
.update(checklistItems)
.set({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.where(eq(checklistItems.id, args.id))
.returning({ id: checklistItems.id });
return result;
};
export const softDeleteAllItemsByChecklistId = async (
db: dbClient,
args: { checklistId: number; deletedAt: Date; deletedBy: string },
) => {
const result = await db
.update(checklistItems)
.set({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.where(
and(
eq(checklistItems.checklistId, args.checklistId),
isNull(checklistItems.deletedAt),
),
)
.returning({ id: checklistItems.id });
return result;
};
export const softDeleteById = async (
db: dbClient,
args: { id: number; deletedAt: Date; deletedBy: string },
) => {
const [result] = await db
.update(checklists)
.set({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.where(eq(checklists.id, args.id))
.returning({ id: checklists.id });
return result;
};
export const updateChecklistById = async (
db: dbClient,
args: { id: number; name: string },
) => {
const [result] = await db
.update(checklists)
.set({ name: args.name, updatedAt: new Date() })
.where(eq(checklists.id, args.id))
.returning({ publicId: checklists.publicId, name: checklists.name });
return result;
};

View File

@@ -12,6 +12,7 @@ import {
varchar,
} from "drizzle-orm/pg-core";
import { checklists } from "./checklists";
import { imports } from "./imports";
import { labels } from "./labels";
import { lists } from "./lists";
@@ -31,6 +32,15 @@ export const activityTypes = [
"card.updated.comment.added",
"card.updated.comment.updated",
"card.updated.comment.deleted",
// Checklist activities
"card.updated.checklist.added",
"card.updated.checklist.renamed",
"card.updated.checklist.deleted",
"card.updated.checklist.item.added",
"card.updated.checklist.item.updated",
"card.updated.checklist.item.completed",
"card.updated.checklist.item.uncompleted",
"card.updated.checklist.item.deleted",
"card.archived",
] as const;
@@ -84,6 +94,7 @@ export const cardsRelations = relations(cards, ({ one, many }) => ({
}),
comments: many(comments),
activities: many(cardActivities),
checklists: many(checklists),
}));
export const cardActivities = pgTable("card_activity", {

View File

@@ -0,0 +1,90 @@
import { relations } from "drizzle-orm";
import {
bigint,
bigserial,
boolean,
integer,
pgTable,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { cards } from "./cards";
import { users } from "./users";
export const checklists = pgTable("card_checklist", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
index: integer("index").notNull(),
cardId: bigint("cardId", { mode: "number" })
.notNull()
.references(() => cards.id, { onDelete: "cascade" }),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
}).enableRLS();
export const checklistsRelations = relations(checklists, ({ one, many }) => ({
card: one(cards, {
fields: [checklists.cardId],
references: [cards.id],
relationName: "checklistsCard",
}),
createdBy: one(users, {
fields: [checklists.createdBy],
references: [users.id],
relationName: "checklistsCreatedByUser",
}),
deletedBy: one(users, {
fields: [checklists.deletedBy],
references: [users.id],
relationName: "checklistsDeletedByUser",
}),
items: many(checklistItems),
}));
export const checklistItems = pgTable("card_checklist_item", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
title: varchar("title", { length: 500 }).notNull(),
completed: boolean("completed").notNull().default(false),
index: integer("index").notNull(),
checklistId: bigint("checklistId", { mode: "number" })
.notNull()
.references(() => checklists.id, { onDelete: "cascade" }),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
}).enableRLS();
export const checklistItemsRelations = relations(checklistItems, ({ one }) => ({
checklist: one(checklists, {
fields: [checklistItems.checklistId],
references: [checklists.id],
relationName: "checklistItemsChecklist",
}),
createdBy: one(users, {
fields: [checklistItems.createdBy],
references: [users.id],
relationName: "checklistItemsCreatedByUser",
}),
deletedBy: one(users, {
fields: [checklistItems.deletedBy],
references: [users.id],
relationName: "checklistItemsDeletedByUser",
}),
}));

View File

@@ -2,6 +2,7 @@ export * from "./auth";
export * from "./boards";
export * from "./auth";
export * from "./cards";
export * from "./checklists";
export * from "./feedback";
export * from "./imports";
export * from "./labels";