diff --git a/app/api/projects/[projectId]/todos/items/reorder/route.ts b/app/api/projects/[projectId]/todos/items/reorder/route.ts new file mode 100644 index 0000000..9af61c5 --- /dev/null +++ b/app/api/projects/[projectId]/todos/items/reorder/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createTodoService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +/** + * カラム内のアイテム順序を一括再採番する(同一カラムの並べ替え / 他カラムからの移動)。 + * body: { columnId: number, itemIds: number[] } —— itemIds の順序で orderIndex 0..n-1 を割り当てる。 + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { projectId } = await params; + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + + const columnId = Number(body.columnId); + const itemIds = Array.isArray(body.itemIds) + ? body.itemIds + .map((n) => Number(n)) + .filter((n) => Number.isFinite(n) && n > 0) + : []; + + const service = createTodoService(); + try { + const items = service.reorderItems( + user.id, + Number(projectId), + columnId, + itemIds + ); + return NextResponse.json({ items }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/components/chat/ChatWindow.tsx b/components/chat/ChatWindow.tsx index f7dd544..a33995a 100644 --- a/components/chat/ChatWindow.tsx +++ b/components/chat/ChatWindow.tsx @@ -81,8 +81,19 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: input, fileIds }), }); - setSending(false); if (res.ok) { + // 送信したメッセージを即座に表示(SSE到着前でも見えるように楽観追加)。 + // SSEの chat.message.created は id で重複排除する。 + const data = (await res.json().catch(() => null)) as { + message?: ChatMessageWithAttachments; + } | null; + if (data?.message) { + setMessages((prev) => + prev.some((m) => m.id === data.message!.id) + ? prev + : [data.message!, ...prev] + ); + } setInput(''); pickerRef.current?.clear(); } else { @@ -91,6 +102,7 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) { } | null; setError(b?.error?.message ?? '送信に失敗しました'); } + setSending(false); } return ( diff --git a/components/todo/KanbanBoard.tsx b/components/todo/KanbanBoard.tsx index f7bd516..bd32b14 100644 --- a/components/todo/KanbanBoard.tsx +++ b/components/todo/KanbanBoard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useState, type DragEvent } from 'react'; +import { useCallback, useRef, useState, type DragEvent } from 'react'; import type { TodoColumn, TodoItem } from '@/lib/types'; import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository'; import { TodoDialog } from '@/components/todo/TodoDialog'; @@ -11,6 +11,11 @@ const PRIORITY_COLORS: Record = { low: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300', }; +/** 同一 orderIndex は id で安定ソート(createdAt 同着対策) */ +function byOrder(a: TodoItem, b: TodoItem): number { + return a.orderIndex - b.orderIndex || a.id - b.id; +} + export function KanbanBoard({ projectId, columns, @@ -25,6 +30,9 @@ export function KanbanBoard({ const [items, setItems] = useState(initialItems); const [draggingId, setDraggingId] = useState(null); const [selectedItem, setSelectedItem] = useState(null); + const [error, setError] = useState(null); + // ドラッグ直後の誤クリック(ダイアログを開く)を抑制するためのタイムスタンプ + const lastDragEndAtRef = useRef(0); const reload = useCallback(async () => { const res = await fetch(`/api/projects/${projectId}/todos/items`); @@ -40,27 +48,84 @@ export function KanbanBoard({ e.dataTransfer.setData('text/plain', String(itemId)); } + function applyReorder( + prev: TodoItem[], + destColumnId: number, + newOrderIds: number[] + ): TodoItem[] { + const order = new Map(newOrderIds.map((id, idx) => [id, idx])); + return prev.map((i) => + order.has(i.id) + ? { ...i, columnId: destColumnId, orderIndex: order.get(i.id)! } + : i + ); + } + + function columnItemIds(columnId: number, excludeId?: number): number[] { + return items + .filter((i) => i.columnId === columnId && i.id !== excludeId) + .sort((a, b) => a.orderIndex - b.orderIndex || a.id - b.id) + .map((i) => i.id); + } + + async function persistReorder( + destColumnId: number, + newOrderIds: number[] + ): Promise { + setItems((prev) => applyReorder(prev, destColumnId, newOrderIds)); + setError(null); + try { + const res = await fetch( + `/api/projects/${projectId}/todos/items/reorder`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + columnId: destColumnId, + itemIds: newOrderIds, + }), + } + ); + if (!res.ok) { + setError('並べ替えに失敗しました'); + } + } catch { + setError('並べ替えに失敗しました'); + } finally { + await reload(); + } + setDraggingId(null); + } + + // カラムの空き領域へのドロップ = 末尾に追加 function onDrop(e: DragEvent, column: TodoColumn) { e.preventDefault(); const itemId = Number(e.dataTransfer.getData('text/plain')); - if (!itemId) return; - const item = items.find((i) => i.id === itemId); - if (!item || item.columnId === column.id) { + if (!itemId) { setDraggingId(null); return; } - // 楽観的にローカル更新したあとAPIで移動(失敗時は再取得で巻き戻す) - setItems((prev) => - prev.map((i) => (i.id === itemId ? { ...i, columnId: column.id } : i)) - ); - fetch(`/api/projects/${projectId}/todos/items/${itemId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ columnId: column.id, orderIndex: 0 }), - }) - .catch(() => undefined) - .finally(() => reload()); - setDraggingId(null); + const newOrderIds = [...columnItemIds(column.id, itemId), itemId]; + void persistReorder(column.id, newOrderIds); + } + + // カード上へのドロップ = ポインタ位置で前後に挿入 + function dropOnItem(e: DragEvent, target: TodoItem) { + e.preventDefault(); + e.stopPropagation(); + const itemId = Number(e.dataTransfer.getData('text/plain')); + if (!itemId || itemId === target.id) { + setDraggingId(null); + return; + } + const rect = e.currentTarget.getBoundingClientRect(); + const before = e.clientY - rect.top < rect.height / 2; + const ordered = columnItemIds(target.columnId, itemId); + const insertAt = before + ? ordered.indexOf(target.id) + : ordered.indexOf(target.id) + 1; + ordered.splice(insertAt, 0, itemId); + void persistReorder(target.columnId, ordered); } async function addTask(columnId: number, title: string) { @@ -82,116 +147,142 @@ export function KanbanBoard({ } return ( -
- {columns.map((column) => { - const colItems = items.filter((i) => i.columnId === column.id); - return ( -
e.preventDefault()} - onDrop={(e) => onDrop(e, column)} - data-testid={`kanban-column-${column.id}`} - data-column-id={column.id} - > -

- {column.name} ({colItems.length}) -

-
    - {colItems.map((item) => { - const tags = item.tags - ?.split(',') - .map((t) => t.trim()) - .filter(Boolean); - const assignee = members.find( - (m) => m.user.id === item.assigneeId - ); - return ( -
  • onDragStart(e, item.id)} - onClick={() => setSelectedItem(item)} - className={`cursor-grab rounded border bg-white dark:bg-gray-800 p-2 shadow-sm ${ - draggingId === item.id ? 'opacity-50' : '' - } ${item.completedAt ? 'opacity-60 line-through' : ''}`} - data-testid={`todo-card-${item.id}`} - > -
    - - {item.title} - - -
    -
    - - {item.priority} - - {item.dueDate && ( - - 期限: {item.dueDate} - - )} - {item.startDate && ( - - 開始: {item.startDate} - - )} - {assignee && ( - - @{assignee.user.name} - - )} -
    - {tags && tags.length > 0 && ( -
    - {tags.map((t) => ( - - {t} - - ))} -
    - )} -
  • - ); - })} -
- addTask(column.id, title)} /> -
- ); - })} - - {selectedItem && ( - setSelectedItem(null)} - onSaved={reload} - /> + <> + {error && ( +

+ {error} +

)} -
+
+ {columns.map((column) => { + const colItems = items + .filter((i) => i.columnId === column.id) + .sort(byOrder); + return ( +
e.preventDefault()} + onDrop={(e) => onDrop(e, column)} + data-testid={`kanban-column-${column.id}`} + data-column-id={column.id} + > +

+ {column.name} ({colItems.length}) +

+
    + {colItems.map((item) => { + const tags = item.tags + ?.split(',') + .map((t) => t.trim()) + .filter(Boolean); + const assignee = members.find( + (m) => m.user.id === item.assigneeId + ); + return ( +
  • onDragStart(e, item.id)} + onDragEnd={() => { + lastDragEndAtRef.current = Date.now(); + setDraggingId(null); + }} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => dropOnItem(e, item)} + onClick={() => { + // ドラッグ直後の誤クリックでダイアログが開かないようにする + if (Date.now() - lastDragEndAtRef.current < 300) return; + setSelectedItem(item); + }} + className={`cursor-grab rounded border bg-white dark:bg-gray-800 p-2 shadow-sm ${ + draggingId === item.id ? 'opacity-50' : '' + } ${item.completedAt ? 'opacity-60 line-through' : ''}`} + data-testid={`todo-card-${item.id}`} + > +
    + + {item.title} + + +
    +
    + + {item.priority} + + {item.dueDate && ( + + 期限: {item.dueDate} + + )} + {item.startDate && ( + + 開始: {item.startDate} + + )} + {assignee && ( + + @{assignee.user.name} + + )} +
    + {tags && tags.length > 0 && ( +
    + {tags.map((t) => ( + + {t} + + ))} +
    + )} +
  • + ); + })} +
+ addTask(column.id, title)} /> +
+ ); + })} + + {selectedItem && ( + setSelectedItem(null)} + onSaved={reload} + /> + )} +
+ ); } diff --git a/services/TodoService.ts b/services/TodoService.ts index 4c15cbb..e39cad3 100644 --- a/services/TodoService.ts +++ b/services/TodoService.ts @@ -235,6 +235,45 @@ export class TodoService { return updated; } + /** + * 指定カラム内のアイテム順序を itemIds の順で再採番する。 + * 同一カラムの並べ替えだけでなく、別カラムからの移動(対象カラムへの挿入)にも使う。 + * すべて同一プロジェクト内であることを検証し、1トランザクションで更新する。 + */ + reorderItems( + actorId: number, + projectId: number, + columnId: number, + itemIds: number[] + ): TodoItem[] { + this.requireMember(projectId, actorId); + const column = this.todoRepository.findColumnById(columnId); + if (!column || column.projectId !== projectId) { + throw new NotFoundError('TodoColumn', columnId); + } + const ids = Array.isArray(itemIds) ? itemIds : []; + if (ids.length === 0) { + // 空の並べ替えは何も変更せず、SSE/ログも出さない + return this.todoRepository + .findItemsByProject(projectId) + .filter((i) => i.columnId === columnId); + } + this.db.transaction(() => { + ids.forEach((id, idx) => { + const item = this.todoRepository.findItemById(id); + if (!item || item.projectId !== projectId) { + throw new NotFoundError('TodoItem', id); + } + this.todoRepository.updateItem(id, { columnId, orderIndex: idx }); + }); + }); + this.logTodo(projectId, actorId, 'todo_updated', ids[0]); + this.broadcastTodo(projectId); + return this.todoRepository + .findItemsByProject(projectId) + .filter((i) => i.columnId === columnId); + } + toggleComplete(actorId: number, itemId: number): TodoItem { const item = this.todoRepository.findItemById(itemId); if (!item) throw new NotFoundError('TodoItem', itemId); diff --git a/tests/e2e/todo-kanban.spec.ts b/tests/e2e/todo-kanban.spec.ts index 7eef379..be08f9b 100644 --- a/tests/e2e/todo-kanban.spec.ts +++ b/tests/e2e/todo-kanban.spec.ts @@ -171,4 +171,58 @@ test.describe('todo / kanban', () => { await expect(page.getByTestId('todo-dialog')).toBeVisible(); await expect(page.getByTestId('attachment-list')).toBeVisible(); }); + + test('reorder cards within a column by drag and drop and persist', async ({ + page, + }) => { + const projectId = await setupOwner(page); + const colsRes = await page.request.get( + `/api/projects/${projectId}/todos/columns` + ); + const cols = ( + (await colsRes.json()) as { columns: { id: number; name: string }[] } + ).columns; + const backlog = cols.find((c) => c.name === 'Backlog')!; + + // 3 つのタスクを Backlog に作成(作成順 A, B, C) + const titles = ['Task-A', 'Task-B', 'Task-C']; + const ids: number[] = []; + for (const title of titles) { + const res = await page.request.post( + `/api/projects/${projectId}/todos/items`, + { data: { title, columnId: backlog.id } } + ); + const { item } = (await res.json()) as { item: { id: number } }; + ids.push(item.id); + } + const [aId, bId, cId] = ids; + + await page.goto(`/projects/${projectId}/todos`); + const col = page.getByTestId(`kanban-column-${backlog.id}`); + + async function orderIds(): Promise { + return col + .locator('[data-testid^="todo-open-"]') + .evaluateAll((els) => + els.map((e) => Number(e.getAttribute('data-testid')!.slice(10))) + ); + } + + await expect(col.getByTestId(`todo-card-${cId}`)).toBeVisible(); + // C を A の上にドラッグ(前へ挿入) -> 順序は C, A, B + await page + .getByTestId(`todo-card-${cId}`) + .dragTo(page.getByTestId(`todo-card-${aId}`), { + targetPosition: { x: 10, y: 2 }, + }); + + await expect + .poll(async () => (await orderIds())[0], { timeout: 10_000 }) + .toBe(cId); + expect(await orderIds()).toEqual([cId, aId, bId]); + + // リロード後も順序が維持される + await page.reload(); + expect(await orderIds()).toEqual([cId, aId, bId]); + }); }); diff --git a/tests/unit/services/TodoService.test.ts b/tests/unit/services/TodoService.test.ts index 627e705..144e521 100644 --- a/tests/unit/services/TodoService.test.ts +++ b/tests/unit/services/TodoService.test.ts @@ -308,4 +308,77 @@ describe('TodoService', () => { expect(cleared.tags).toBeNull(); expect(cleared.assigneeId).toBeNull(); }); + + describe('reorderItems', () => { + it('renumbers items within a column in the given order', () => { + const cols = service.getColumns(authorId, projectId); + const col = cols[0]; + const a = service.createItem(authorId, projectId, { + title: 'A', + columnId: col.id, + }); + const b = service.createItem(authorId, projectId, { + title: 'B', + columnId: col.id, + }); + const c = service.createItem(authorId, projectId, { + title: 'C', + columnId: col.id, + }); + // 逆順に並べ替え + const result = service.reorderItems(authorId, projectId, col.id, [ + c.id, + b.id, + a.id, + ]); + expect(result.map((i) => i.title)).toEqual(['C', 'B', 'A']); + expect(result.map((i) => i.orderIndex)).toEqual([0, 1, 2]); + }); + + it('moves an item from another column into the target position', () => { + const cols = service.getColumns(authorId, projectId); + const src = cols[0]; + const dest = cols[1]; + const a = service.createItem(authorId, projectId, { + title: 'A', + columnId: dest.id, + }); + const b = service.createItem(authorId, projectId, { + title: 'B', + columnId: dest.id, + }); + const x = service.createItem(authorId, projectId, { + title: 'X', + columnId: src.id, + }); + // X を A と B の間に挿入 + service.reorderItems(authorId, projectId, dest.id, [a.id, x.id, b.id]); + const destItems = service + .getItems(authorId, projectId) + .filter((i) => i.columnId === dest.id); + expect(destItems.map((i) => i.title)).toEqual(['A', 'X', 'B']); + // X は元のカラムからいなくなる + const srcItems = service + .getItems(authorId, projectId) + .filter((i) => i.columnId === src.id); + expect(srcItems.find((i) => i.id === x.id)).toBeUndefined(); + }); + + it('forbids a non-member from reordering', () => { + const col = service.getColumns(authorId, projectId)[0]; + expect(() => + service.reorderItems(outsiderId, projectId, col.id, []) + ).toThrow(ForbiddenError); + }); + + it('rejects an unknown column or item', () => { + const col = service.getColumns(authorId, projectId)[0]; + expect(() => + service.reorderItems(authorId, projectId, 99999, []) + ).toThrow(NotFoundError); + expect(() => + service.reorderItems(authorId, projectId, col.id, [99999]) + ).toThrow(NotFoundError); + }); + }); });