Merge branch 'feature/fix-chat-immediate-todo-reorder' - chat immediate display + todo reorder
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
This commit is contained in:
46
app/api/projects/[projectId]/todos/items/reorder/route.ts
Normal file
46
app/api/projects/[projectId]/todos/items/reorder/route.ts
Normal file
@ -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<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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<string, string> = {
|
||||
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<TodoItem[]>(initialItems);
|
||||
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<TodoItem | null>(null);
|
||||
const [error, setError] = useState<string | null>(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<void> {
|
||||
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<HTMLElement>, 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());
|
||||
const newOrderIds = [...columnItemIds(column.id, itemId), itemId];
|
||||
void persistReorder(column.id, newOrderIds);
|
||||
}
|
||||
|
||||
// カード上へのドロップ = ポインタ位置で前後に挿入
|
||||
function dropOnItem(e: DragEvent<HTMLElement>, 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,9 +147,24 @@ export function KanbanBoard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4" data-testid="kanban-board">
|
||||
<>
|
||||
{error && (
|
||||
<p
|
||||
className="mb-2 text-sm text-red-600"
|
||||
role="alert"
|
||||
data-testid="kanban-error"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
className="flex gap-4 overflow-x-auto pb-4"
|
||||
data-testid="kanban-board"
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const colItems = items.filter((i) => i.columnId === column.id);
|
||||
const colItems = items
|
||||
.filter((i) => i.columnId === column.id)
|
||||
.sort(byOrder);
|
||||
return (
|
||||
<section
|
||||
key={column.id}
|
||||
@ -111,7 +191,17 @@ export function KanbanBoard({
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, item.id)}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
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' : ''}`}
|
||||
@ -192,6 +282,7 @@ export function KanbanBoard({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<number[]> {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user