* 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>
425 lines
10 KiB
TypeScript
425 lines
10 KiB
TypeScript
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: {
|
|
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;
|
|
completed?: boolean;
|
|
},
|
|
) => {
|
|
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: checklistItemInput.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;
|
|
};
|
|
|
|
export const bulkCreate = async (
|
|
db: dbClient,
|
|
checklistInput: {
|
|
cardId: number;
|
|
name: string;
|
|
createdBy: string;
|
|
index: number;
|
|
}[],
|
|
) => {
|
|
if (checklistInput.length === 0) return [];
|
|
|
|
return db.transaction(async (tx) => {
|
|
const byCard = groupByKey(checklistInput, "cardId");
|
|
|
|
const allValuesToInsert: {
|
|
publicId: string;
|
|
cardId: number;
|
|
name: string;
|
|
createdBy: string;
|
|
index: number;
|
|
}[] = [];
|
|
|
|
for (const [cardId, items] of byCard.entries()) {
|
|
const last = await tx.query.checklists.findFirst({
|
|
columns: { index: true },
|
|
where: and(eq(checklists.cardId, cardId), isNull(checklists.deletedAt)),
|
|
orderBy: [desc(checklists.index)],
|
|
});
|
|
|
|
let nextIndex = last ? last.index + 1 : 0;
|
|
|
|
const sorted = [...items].sort((a, b) => a.index - b.index);
|
|
|
|
for (const item of sorted) {
|
|
allValuesToInsert.push({
|
|
publicId: generateUID(),
|
|
...item,
|
|
index: nextIndex++,
|
|
});
|
|
}
|
|
}
|
|
|
|
const inserted = await tx
|
|
.insert(checklists)
|
|
.values(allValuesToInsert)
|
|
.returning({ id: checklists.id, publicId: checklists.publicId });
|
|
|
|
return inserted;
|
|
});
|
|
};
|
|
|
|
export const bulkCreateItems = async (
|
|
db: dbClient,
|
|
checklistItemInput: {
|
|
checklistId: number;
|
|
title: string;
|
|
createdBy: string;
|
|
index: number;
|
|
completed: boolean;
|
|
}[],
|
|
) => {
|
|
if (checklistItemInput.length === 0) return [];
|
|
|
|
return db.transaction(async (tx) => {
|
|
const byChecklist = groupByKey(checklistItemInput, "checklistId");
|
|
|
|
const allValuesToInsert: {
|
|
publicId: string;
|
|
checklistId: number;
|
|
title: string;
|
|
createdBy: string;
|
|
index: number;
|
|
completed: boolean;
|
|
}[] = [];
|
|
|
|
for (const [checklistId, items] of byChecklist.entries()) {
|
|
const last = await tx.query.checklistItems.findFirst({
|
|
columns: { index: true },
|
|
where: and(
|
|
eq(checklistItems.checklistId, checklistId),
|
|
isNull(checklistItems.deletedAt),
|
|
),
|
|
orderBy: [desc(checklistItems.index)],
|
|
});
|
|
|
|
let nextIndex = last ? last.index + 1 : 0;
|
|
|
|
const sorted = [...items].sort((a, b) => a.index - b.index);
|
|
|
|
for (const item of sorted) {
|
|
allValuesToInsert.push({
|
|
publicId: generateUID(),
|
|
...item,
|
|
index: nextIndex++,
|
|
});
|
|
}
|
|
}
|
|
|
|
const inserted = await tx
|
|
.insert(checklistItems)
|
|
.values(allValuesToInsert)
|
|
.returning({
|
|
id: checklistItems.id,
|
|
publicId: checklistItems.publicId,
|
|
title: checklistItems.title,
|
|
completed: checklistItems.completed,
|
|
});
|
|
|
|
return inserted;
|
|
});
|
|
};
|
|
|
|
const groupByKey = <T extends Record<string, unknown>>(
|
|
items: T[],
|
|
keyField: keyof T,
|
|
): Map<number, T[]> => {
|
|
const grouped = new Map<number, T[]>();
|
|
for (const item of items) {
|
|
const key = item[keyField] as number;
|
|
const arr = grouped.get(key) ?? [];
|
|
arr.push(item);
|
|
grouped.set(key, arr);
|
|
}
|
|
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;
|
|
});
|
|
}
|
|
|