feat: implement drag-and-drop reordering for checklist items (#275)
* feat: implement drag-and-drop reordering for checklist items * WIP: Changes before syncing with main * feat: update drag icon and positioning * refactor: consolidate checklist item updates into single endpoint - Remove standalone reorderItem route (now part of updateItem) - Add optional index parameter to updateItem for reordering - Change updateItem from PUT to PATCH method - Add deletedAt IS NULL filter to reorderItem SQL queries - Follows existing pattern from card.update route * feat: add optimistic updates * chore: translations --------- Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
committed by
GitHub
parent
6ebab28606
commit
3cb40d0f9a
@@ -269,7 +269,7 @@ export const checklistRouter = createTRPCRouter({
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update a checklist item",
|
||||
method: "PUT",
|
||||
method: "PATCH",
|
||||
path: "/checklists/items/{checklistItemPublicId}",
|
||||
description: "Updates a checklist item (title/completed)",
|
||||
tags: ["Cards"],
|
||||
@@ -281,6 +281,7 @@ export const checklistRouter = createTRPCRouter({
|
||||
checklistItemPublicId: z.string().length(12),
|
||||
title: z.string().min(1).max(500).optional(),
|
||||
completed: z.boolean().optional(),
|
||||
index: z.number().int().min(0).optional(),
|
||||
}),
|
||||
)
|
||||
.output(checklistItemSchema)
|
||||
@@ -312,17 +313,30 @@ export const checklistRouter = createTRPCRouter({
|
||||
|
||||
const previousTitle = item.title;
|
||||
|
||||
const updated = await checklistRepo.updateItemById(ctx.db, {
|
||||
id: item.id,
|
||||
title: input.title,
|
||||
completed: input.completed,
|
||||
});
|
||||
let updatedItem;
|
||||
|
||||
if (input.title !== undefined || input.completed !== undefined) {
|
||||
updatedItem = await checklistRepo.updateItemById(ctx.db, {
|
||||
id: item.id,
|
||||
title: input.title,
|
||||
completed: input.completed,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.index !== undefined) {
|
||||
updatedItem = await checklistRepo.reorderItem(ctx.db, {
|
||||
itemId: item.id,
|
||||
newIndex: input.index,
|
||||
});
|
||||
}
|
||||
|
||||
if (!updatedItem) {
|
||||
throw new TRPCError({
|
||||
message: `Failed to update checklist item`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
if (!updated)
|
||||
throw new TRPCError({
|
||||
message: `Failed to update checklist item`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
// Log completion toggle
|
||||
if (input.completed !== undefined) {
|
||||
@@ -331,7 +345,7 @@ export const checklistRouter = createTRPCRouter({
|
||||
? "card.updated.checklist.item.completed"
|
||||
: "card.updated.checklist.item.uncompleted",
|
||||
cardId: item.checklist.cardId,
|
||||
toTitle: updated.title,
|
||||
toTitle: updatedItem.title,
|
||||
createdBy: userId,
|
||||
});
|
||||
}
|
||||
@@ -342,12 +356,13 @@ export const checklistRouter = createTRPCRouter({
|
||||
type: "card.updated.checklist.item.updated",
|
||||
cardId: item.checklist.cardId,
|
||||
fromTitle: previousTitle,
|
||||
toTitle: updated.title,
|
||||
toTitle: updatedItem.title,
|
||||
createdBy: userId,
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
return updatedItem;
|
||||
|
||||
}),
|
||||
deleteItem: protectedProcedure
|
||||
.meta({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { and, desc, eq, isNull, sql } 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: {
|
||||
@@ -341,3 +342,83 @@ const groupByKey = <T extends Record<string, unknown>>(
|
||||
}
|
||||
return grouped;
|
||||
};
|
||||
|
||||
export const reorderItem = async (
|
||||
db: dbClient,
|
||||
args: {
|
||||
itemId: number;
|
||||
newIndex: number;
|
||||
},
|
||||
) => {
|
||||
return db.transaction(async (tx) => {
|
||||
const item = await tx.query.checklistItems.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
index: true,
|
||||
checklistId: true,
|
||||
},
|
||||
where: and(eq(checklistItems.id, args.itemId), isNull(checklistItems.deletedAt)),
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new Error(`Checklist item not found for ID ${args.itemId}`);
|
||||
}
|
||||
|
||||
const currentIndex = item.index;
|
||||
const newIndex = args.newIndex;
|
||||
|
||||
if (currentIndex === newIndex) {
|
||||
const unchanged = await tx.query.checklistItems.findFirst({
|
||||
columns: {
|
||||
publicId: true,
|
||||
title: true,
|
||||
completed: true,
|
||||
},
|
||||
where: and(eq(checklistItems.id, args.itemId), isNull(checklistItems.deletedAt)),
|
||||
});
|
||||
|
||||
if (!unchanged) {
|
||||
throw new Error(`Checklist item not found for ID ${args.itemId}`);
|
||||
}
|
||||
|
||||
return unchanged;
|
||||
}
|
||||
|
||||
if (currentIndex < newIndex) {
|
||||
await tx.execute(sql`
|
||||
UPDATE card_checklist_item
|
||||
SET index = index - 1
|
||||
WHERE "checklistId" = ${item.checklistId}
|
||||
AND index > ${currentIndex}
|
||||
AND index <= ${newIndex}
|
||||
AND "deletedAt" IS NULL
|
||||
`);
|
||||
} else {
|
||||
await tx.execute(sql`
|
||||
UPDATE card_checklist_item
|
||||
SET index = index + 1
|
||||
WHERE "checklistId" = ${item.checklistId}
|
||||
AND index >= ${newIndex}
|
||||
AND index < ${currentIndex}
|
||||
AND "deletedAt" IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
const [updated] = await tx
|
||||
.update(checklistItems)
|
||||
.set({ index: newIndex })
|
||||
.where(eq(checklistItems.id, args.itemId))
|
||||
.returning({
|
||||
publicId: checklistItems.publicId,
|
||||
title: checklistItems.title,
|
||||
completed: checklistItems.completed,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Failed to update checklist item with ID ${args.itemId}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user