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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
216
packages/db/src/repository/checklist.repo.ts
Normal file
216
packages/db/src/repository/checklist.repo.ts
Normal 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;
|
||||
};
|
||||
@@ -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", {
|
||||
|
||||
90
packages/db/src/schema/checklists.ts
Normal file
90
packages/db/src/schema/checklists.ts
Normal 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",
|
||||
}),
|
||||
}));
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user