diff --git a/apps/web/src/components/TodoPanel.tsx b/apps/web/src/components/TodoPanel.tsx index 7efec8651374..bbbc29ad1ecc 100644 --- a/apps/web/src/components/TodoPanel.tsx +++ b/apps/web/src/components/TodoPanel.tsx @@ -1,4 +1,17 @@ import { useCallback, useMemo, useState } from "react"; +import { + DndContext, + DragCancelEvent, + DragEndEvent, + DragOverlay, + DragStartEvent, + closestCenter, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { CheckCircle2Icon, ChevronDownIcon, @@ -8,6 +21,7 @@ import { ExternalLinkIcon, EyeIcon, EyeOffIcon, + GripVerticalIcon, PlusIcon, } from "lucide-react"; import type { TodoCategory, TodoItem, ContextMenuItem, TodoMutation } from "@t3tools/contracts"; @@ -43,14 +57,14 @@ function countActiveItems(items: TodoItem[], categoryId: string): number { export function TodoPanel() { const { categories, items, loading, error, mutate } = useTodos(); const [hideEmpty, setHideEmpty] = useState(false); + const [draggedItem, setDraggedItem] = useState(null); + const [draggedCategory, setDraggedCategory] = useState(null); - const handleCreateCategory = () => { - mutate([{ type: "createCategory", name: "New Category", color: "" }] as TodoMutation[]); - }; - - const handleCycleItem = (itemId: string) => { - mutate([{ type: "cycleItemStatus", itemId }]); - }; + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 4 }, + }), + ); const filteredCategories = hideEmpty ? categories.filter((cat) => countActiveItems(items, cat.id) > 0) @@ -69,8 +83,123 @@ export function TodoPanel() { [items], ); + const handleCreateCategory = () => { + mutate([{ type: "createCategory", name: "New Category", color: "" }] as TodoMutation[]); + }; + + const handleCycleItem = (itemId: string) => { + mutate([{ type: "cycleItemStatus", itemId }]); + }; + + const handleDragStart = useCallback( + (event: DragStartEvent) => { + const { active } = event; + if (active.data.current?.kind === "item") { + const item = items.find((i) => i.id === active.id); + if (item) setDraggedItem(item); + } else if (active.data.current?.kind === "category") { + const catId = (active.id as string).replace("category:", ""); + const cat = categories.find((c) => c.id === catId); + if (cat) setDraggedCategory(cat); + } + }, + [items, categories], + ); + + const handleDragCancel = useCallback(() => { + setDraggedItem(null); + setDraggedCategory(null); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + setDraggedItem(null); + setDraggedCategory(null); + + const { active, over } = event; + if (!over || active.id === over.id) return; + + const activeData = active.data.current; + const overData = over.data.current; + + if (activeData?.kind === "category") { + const activeCatId = (active.id as string).replace("category:", ""); + const overCatId = (over.id as string).replace("category:", ""); + + const oldIdx = categories.findIndex((c) => c.id === activeCatId); + const newIdx = categories.findIndex((c) => c.id === overCatId); + if (oldIdx !== -1 && newIdx !== -1 && oldIdx !== newIdx) { + const orderedIds = categories.map((c) => c.id); + const [moved] = orderedIds.splice(oldIdx, 1); + orderedIds.splice(newIdx, 0, moved!); + mutate([{ type: "reorderCategories", orderedIds }]); + } + return; + } + + if (activeData?.kind === "item") { + const activeItem = items.find((i) => i.id === active.id); + if (!activeItem) return; + + const fromCatId = activeItem.categoryId; + const activeItemsAll = items.filter( + (i) => i.status === "todo" || i.status === "in_progress", + ); + + const toCatId = + overData?.kind === "item" + ? overData.categoryId + : overData?.kind === "category" + ? overData.categoryId + : fromCatId; + + const targetItems = activeItemsAll + .filter((i) => i.categoryId === toCatId && i.id !== activeItem.id) + .toSorted((a, b) => a.sortOrder - b.sortOrder); + + let insertIndex = targetItems.length; + if (overData?.kind === "item" && overData.categoryId === toCatId) { + insertIndex = targetItems.findIndex((i) => i.id === over.id); + if (insertIndex === -1) insertIndex = targetItems.length; + } + + targetItems.splice(insertIndex, 0, activeItem); + + const affected: Array<{ id: string; categoryId: string; sortOrder: number }> = []; + + if (fromCatId !== toCatId) { + const fromItems = activeItemsAll + .filter((i) => i.categoryId === fromCatId && i.id !== activeItem.id) + .toSorted((a, b) => a.sortOrder - b.sortOrder); + + fromItems.forEach((item, idx) => { + affected.push({ id: item.id, categoryId: fromCatId, sortOrder: idx }); + }); + } + + targetItems.forEach((item, idx) => { + affected.push({ id: item.id, categoryId: toCatId, sortOrder: idx }); + }); + + mutate([{ type: "reorderItems", updates: affected }]); + } + }, + [items, categories, mutate], + ); + + const categorySortableIds = useMemo( + () => filteredCategories.map((c) => `category:${c.id}`), + [filteredCategories], + ); + return ( - <> + Todos
@@ -95,15 +224,17 @@ export function TodoPanel() { {!loading && !error && filteredCategories.length === 0 && doneItems.length === 0 && (
No todos yet
)} - {filteredCategories.map((category) => ( - - ))} + + {filteredCategories.map((category) => ( + + ))} + {doneItems.length > 0 && ( )} - + + {draggedItem && ( +
+ + {draggedItem.title} +
+ )} + {draggedCategory && ( +
+
+ {draggedCategory.name} +
+ )} + + + ); +} + +function SortableCategoryHeader({ + category, + collapsed, + onToggle, + renaming, + renameValue, + onRenameChange, + onRenameKeyDown, + onRenameCommit, + onDoubleClick, + onContextMenu, + jiraKey, + onAddItem, +}: { + category: TodoCategory; + collapsed: boolean; + onToggle: () => void; + renaming: boolean; + renameValue: string; + onRenameChange: (v: string) => void; + onRenameKeyDown: (e: React.KeyboardEvent) => void; + onRenameCommit: () => void; + onDoubleClick: () => void; + onContextMenu: (e: React.MouseEvent) => void; + jiraKey: string | null; + onAddItem: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: `category:${category.id}`, + data: { kind: "category", categoryId: category.id }, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + }; + + return ( +
+ + +
+ ); +} + +function SortableTodoItem({ + item, + onCycleItem, +}: { + item: TodoItem; + onCycleItem: (itemId: string) => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: item.id, + data: { kind: "item", categoryId: item.categoryId }, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + }; + + return ( +
+
+ +
+ + {item.title} +
); } @@ -264,54 +542,26 @@ function TodoCategoryRow({ item.categoryId === category.id && (item.status === "todo" || item.status === "in_progress"), ) - .toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); + .toSorted((a, b) => a.sortOrder - b.sortOrder); + + const itemSortableIds = useMemo(() => categoryItems.map((i) => i.id), [categoryItems]); return (
- - + onContextMenu={handleContextMenu} + jiraKey={jiraKey} + onAddItem={startAdd} + /> {!collapsed && (
{adding && ( @@ -330,21 +580,11 @@ function TodoCategoryRow({ {categoryItems.length === 0 ? (
No items
) : ( - categoryItems.map((item) => ( -
- - {item.title} -
- )) + + {categoryItems.map((item) => ( + + ))} + )}
)} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b5ec1e526717..51361fbbe849 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -447,6 +447,8 @@ export const TodoMutationType = Schema.Literals([ "deleteCategory", "createItem", "cycleItemStatus", + "reorderItems", + "reorderCategories", ]); export type TodoMutationType = typeof TodoMutationType.Type; @@ -486,6 +488,22 @@ export const CycleItemStatusMutation = Schema.Struct({ itemId: Schema.String, }); +export const ReorderItemsMutation = Schema.Struct({ + type: Schema.Literal("reorderItems"), + updates: Schema.Array( + Schema.Struct({ + id: Schema.String, + categoryId: Schema.String, + sortOrder: Schema.Number, + }), + ), +}); + +export const ReorderCategoriesMutation = Schema.Struct({ + type: Schema.Literal("reorderCategories"), + orderedIds: Schema.Array(Schema.String), +}); + export const TodoMutation = Schema.Union([ CreateCategoryMutation, RenameCategoryMutation, @@ -494,6 +512,8 @@ export const TodoMutation = Schema.Union([ DeleteCategoryMutation, CreateItemMutation, CycleItemStatusMutation, + ReorderItemsMutation, + ReorderCategoriesMutation, ]); export type TodoMutation = typeof TodoMutation.Type; diff --git a/packages/shared/src/todoStore.test.ts b/packages/shared/src/todoStore.test.ts index 6e61af6bbd5d..b310bc90f9ca 100644 --- a/packages/shared/src/todoStore.test.ts +++ b/packages/shared/src/todoStore.test.ts @@ -12,6 +12,7 @@ import { loadTodos, renameCategory, reorderCategories, + reorderItems, setCategoryColor, setCategoryJiraLink, toggleCategory, @@ -165,6 +166,69 @@ describe("todoStore", () => { }); }); + describe("reorderItems", () => { + it("updates sortOrder for items within the same category", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = reorderItems(state, [{ id: "item-1", categoryId: "cat-1", sortOrder: 5 }]); + + expect(next.items[0]!.sortOrder).toBe(5); + expect(next.items[0]!.categoryId).toBe("cat-1"); + expect(next.items[0]!.updatedAt).not.toBe(sampleItems[0]!.updatedAt); + }); + + it("recategorizes an item by changing its categoryId", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = reorderItems(state, [{ id: "item-1", categoryId: "cat-2", sortOrder: 10 }]); + + expect(next.items[0]!.categoryId).toBe("cat-2"); + expect(next.items[0]!.sortOrder).toBe(10); + }); + + it("reorders and recategorizes multiple items at once", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = reorderItems(state, [ + { id: "item-1", categoryId: "cat-2", sortOrder: 0 }, + { id: "item-2", categoryId: "cat-1", sortOrder: 0 }, + ]); + + expect(next.items[0]!.categoryId).toBe("cat-2"); + expect(next.items[0]!.sortOrder).toBe(0); + expect(next.items[1]!.categoryId).toBe("cat-1"); + expect(next.items[1]!.sortOrder).toBe(0); + }); + + it("does not mutate the original state", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = reorderItems(state, [{ id: "item-1", categoryId: "cat-2", sortOrder: 0 }]); + + expect(next).not.toBe(state); + expect(next.items).not.toBe(state.items); + expect(state.items[0]!.categoryId).toBe("cat-1"); + expect(state.items[0]!.sortOrder).toBe(0); + }); + + it("leaves unmatched items unchanged", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = reorderItems(state, [{ id: "nonexistent", categoryId: "cat-2", sortOrder: 99 }]); + + expect(next.items[0]!.sortOrder).toBe(0); + expect(next.items[0]!.categoryId).toBe("cat-1"); + expect(next.items[1]!.sortOrder).toBe(1); + expect(next.items[1]!.categoryId).toBe("cat-2"); + }); + + it("updates updatedAt on all changed items", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = reorderItems(state, [ + { id: "item-1", categoryId: "cat-1", sortOrder: 3 }, + { id: "item-2", categoryId: "cat-2", sortOrder: 7 }, + ]); + + expect(next.items[0]!.updatedAt).not.toBe(sampleItems[0]!.updatedAt); + expect(next.items[1]!.updatedAt).not.toBe(sampleItems[1]!.updatedAt); + }); + }); + describe("createCategory", () => { it("adds a new category with default name", () => { const state = loadTodos([], []); diff --git a/packages/shared/src/todoStore.ts b/packages/shared/src/todoStore.ts index b9851d722db1..51461dac9f31 100644 --- a/packages/shared/src/todoStore.ts +++ b/packages/shared/src/todoStore.ts @@ -18,7 +18,7 @@ export function toggleCategory(state: TodoState, categoryId: string): TodoState }; } -export function reorderCategories(state: TodoState, orderedIds: string[]): TodoState { +export function reorderCategories(state: TodoState, orderedIds: ReadonlyArray): TodoState { const orderMap = new Map(orderedIds.map((id, i) => [id, i])); return { ...state, @@ -28,6 +28,31 @@ export function reorderCategories(state: TodoState, orderedIds: string[]): TodoS }; } +export function reorderItems( + state: TodoState, + updates: ReadonlyArray<{ + readonly id: string; + readonly categoryId: string; + readonly sortOrder: number; + }>, +): TodoState { + const updateMap = new Map(updates.map((u) => [u.id, u])); + return { + ...state, + items: state.items.map((item) => { + const update = updateMap.get(item.id); + if (!update) return item; + return { + ...item, + categoryId: update.categoryId, + sortOrder: update.sortOrder, + // @effect-diagnostics-next-line globalDate:off + updatedAt: new Date().toISOString(), + }; + }), + }; +} + export function createCategory(state: TodoState): TodoState { const category: TodoCategory = { id: crypto.randomUUID(), @@ -141,6 +166,10 @@ export function applyMutation(state: TodoState, mutation: TodoMutation): TodoSta return createItem(state, mutation.categoryId, mutation.title); case "cycleItemStatus": return cycleItemStatus(state, mutation.itemId); + case "reorderItems": + return reorderItems(state, mutation.updates); + case "reorderCategories": + return reorderCategories(state, mutation.orderedIds); default: return state; }