1 Commits

Author SHA1 Message Date
03cf27a148 fix(chat,todo): show sent chat messages immediately + kanban drag-and-drop reorder
チャット送信の即時表示とKanbanのDnD並べ替えを修正。

- chat: 送信成功時に返却メッセージを楽観的にローカルへ先頭追加(SSE到着前でも即表示、idで重複排除)。setSending(false) をレスポンス処理後に移動し二重送信窓を縮小。
- todo: TodoService.reorderItems(projectId, columnId, itemIds[]) を追加(1トランザクションでカラム内を再採番、メンバーシップ+同プロジェクト検証、活動ログ/SSEは1件)。POST /todos/items/reorder を追加。
- KanbanBoard: カード単位のドロップターゲット(ポインタYで前/後挿入)+カラム空き領域は末尾追加。楽観的にorderIndex/columnIdを更新しrenderをorderIndexでソート(即時反映)、失敗時はエラー表示+再取得で巻き戻し。ドラッグ直後の誤クリック(ダイアログ誘起)をdragEndタイムスタンプで抑制。空の並べ替えはSSE/ログを出さない。
2026-06-25 13:06:05 +02:00
6 changed files with 441 additions and 126 deletions

View 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);
}
}

View File

@ -81,8 +81,19 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: input, fileIds }), body: JSON.stringify({ body: input, fileIds }),
}); });
setSending(false);
if (res.ok) { 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(''); setInput('');
pickerRef.current?.clear(); pickerRef.current?.clear();
} else { } else {
@ -91,6 +102,7 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
} | null; } | null;
setError(b?.error?.message ?? '送信に失敗しました'); setError(b?.error?.message ?? '送信に失敗しました');
} }
setSending(false);
} }
return ( return (

View File

@ -1,6 +1,6 @@
'use client'; '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 { TodoColumn, TodoItem } from '@/lib/types';
import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository'; import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository';
import { TodoDialog } from '@/components/todo/TodoDialog'; 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', 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({ export function KanbanBoard({
projectId, projectId,
columns, columns,
@ -25,6 +30,9 @@ export function KanbanBoard({
const [items, setItems] = useState<TodoItem[]>(initialItems); const [items, setItems] = useState<TodoItem[]>(initialItems);
const [draggingId, setDraggingId] = useState<number | null>(null); const [draggingId, setDraggingId] = useState<number | null>(null);
const [selectedItem, setSelectedItem] = useState<TodoItem | 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 reload = useCallback(async () => {
const res = await fetch(`/api/projects/${projectId}/todos/items`); const res = await fetch(`/api/projects/${projectId}/todos/items`);
@ -40,27 +48,84 @@ export function KanbanBoard({
e.dataTransfer.setData('text/plain', String(itemId)); 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) { function onDrop(e: DragEvent<HTMLElement>, column: TodoColumn) {
e.preventDefault(); e.preventDefault();
const itemId = Number(e.dataTransfer.getData('text/plain')); const itemId = Number(e.dataTransfer.getData('text/plain'));
if (!itemId) return; if (!itemId) {
const item = items.find((i) => i.id === itemId);
if (!item || item.columnId === column.id) {
setDraggingId(null); setDraggingId(null);
return; return;
} }
// 楽観的にローカル更新したあとAPIで移動(失敗時は再取得で巻き戻す) const newOrderIds = [...columnItemIds(column.id, itemId), itemId];
setItems((prev) => void persistReorder(column.id, newOrderIds);
prev.map((i) => (i.id === itemId ? { ...i, columnId: column.id } : i)) }
);
fetch(`/api/projects/${projectId}/todos/items/${itemId}`, { // カード上へのドロップ = ポインタ位置で前後に挿入
method: 'PATCH', function dropOnItem(e: DragEvent<HTMLElement>, target: TodoItem) {
headers: { 'Content-Type': 'application/json' }, e.preventDefault();
body: JSON.stringify({ columnId: column.id, orderIndex: 0 }), e.stopPropagation();
}) const itemId = Number(e.dataTransfer.getData('text/plain'));
.catch(() => undefined) if (!itemId || itemId === target.id) {
.finally(() => reload()); setDraggingId(null);
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) { async function addTask(columnId: number, title: string) {
@ -82,116 +147,142 @@ export function KanbanBoard({
} }
return ( return (
<div className="flex gap-4 overflow-x-auto pb-4" data-testid="kanban-board"> <>
{columns.map((column) => { {error && (
const colItems = items.filter((i) => i.columnId === column.id); <p
return ( className="mb-2 text-sm text-red-600"
<section role="alert"
key={column.id} data-testid="kanban-error"
className="w-64 shrink-0 rounded-lg bg-gray-100 dark:bg-gray-700 p-3" >
onDragOver={(e) => e.preventDefault()} {error}
onDrop={(e) => onDrop(e, column)} </p>
data-testid={`kanban-column-${column.id}`}
data-column-id={column.id}
>
<h2 className="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-200">
{column.name} ({colItems.length})
</h2>
<ul className="space-y-2">
{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 (
<li
key={item.id}
draggable
onDragStart={(e) => 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}`}
>
<div className="flex items-start justify-between gap-2">
<span
className="text-sm hover:text-blue-600 hover:underline"
data-testid={`todo-open-${item.id}`}
>
{item.title}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleComplete(item.id);
}}
className="text-xs text-blue-600 hover:underline"
data-testid={`todo-complete-${item.id}`}
>
{item.completedAt ? '戻す' : '完了'}
</button>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1">
<span
className={`rounded px-1.5 py-0.5 text-xs ${
PRIORITY_COLORS[item.priority] ??
PRIORITY_COLORS.normal
}`}
>
{item.priority}
</span>
{item.dueDate && (
<span className="text-xs text-gray-500 dark:text-gray-400">
: {item.dueDate}
</span>
)}
{item.startDate && (
<span className="text-xs text-gray-400 dark:text-gray-500">
: {item.startDate}
</span>
)}
{assignee && (
<span className="text-xs text-gray-500 dark:text-gray-400">
@{assignee.user.name}
</span>
)}
</div>
{tags && tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{tags.map((t) => (
<span
key={t}
className="rounded bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 text-[10px] text-gray-600 dark:text-gray-300"
>
{t}
</span>
))}
</div>
)}
</li>
);
})}
</ul>
<NewTaskInput onAdd={(title) => addTask(column.id, title)} />
</section>
);
})}
{selectedItem && (
<TodoDialog
projectId={projectId}
item={selectedItem}
members={members}
onClose={() => setSelectedItem(null)}
onSaved={reload}
/>
)} )}
</div> <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)
.sort(byOrder);
return (
<section
key={column.id}
className="w-64 shrink-0 rounded-lg bg-gray-100 dark:bg-gray-700 p-3"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDrop(e, column)}
data-testid={`kanban-column-${column.id}`}
data-column-id={column.id}
>
<h2 className="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-200">
{column.name} ({colItems.length})
</h2>
<ul className="space-y-2">
{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 (
<li
key={item.id}
draggable
onDragStart={(e) => 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}`}
>
<div className="flex items-start justify-between gap-2">
<span
className="text-sm hover:text-blue-600 hover:underline"
data-testid={`todo-open-${item.id}`}
>
{item.title}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleComplete(item.id);
}}
className="text-xs text-blue-600 hover:underline"
data-testid={`todo-complete-${item.id}`}
>
{item.completedAt ? '戻す' : '完了'}
</button>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1">
<span
className={`rounded px-1.5 py-0.5 text-xs ${
PRIORITY_COLORS[item.priority] ??
PRIORITY_COLORS.normal
}`}
>
{item.priority}
</span>
{item.dueDate && (
<span className="text-xs text-gray-500 dark:text-gray-400">
: {item.dueDate}
</span>
)}
{item.startDate && (
<span className="text-xs text-gray-400 dark:text-gray-500">
: {item.startDate}
</span>
)}
{assignee && (
<span className="text-xs text-gray-500 dark:text-gray-400">
@{assignee.user.name}
</span>
)}
</div>
{tags && tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{tags.map((t) => (
<span
key={t}
className="rounded bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 text-[10px] text-gray-600 dark:text-gray-300"
>
{t}
</span>
))}
</div>
)}
</li>
);
})}
</ul>
<NewTaskInput onAdd={(title) => addTask(column.id, title)} />
</section>
);
})}
{selectedItem && (
<TodoDialog
projectId={projectId}
item={selectedItem}
members={members}
onClose={() => setSelectedItem(null)}
onSaved={reload}
/>
)}
</div>
</>
); );
} }

View File

@ -235,6 +235,45 @@ export class TodoService {
return updated; 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 { toggleComplete(actorId: number, itemId: number): TodoItem {
const item = this.todoRepository.findItemById(itemId); const item = this.todoRepository.findItemById(itemId);
if (!item) throw new NotFoundError('TodoItem', itemId); if (!item) throw new NotFoundError('TodoItem', itemId);

View File

@ -171,4 +171,58 @@ test.describe('todo / kanban', () => {
await expect(page.getByTestId('todo-dialog')).toBeVisible(); await expect(page.getByTestId('todo-dialog')).toBeVisible();
await expect(page.getByTestId('attachment-list')).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]);
});
}); });

View File

@ -308,4 +308,77 @@ describe('TodoService', () => {
expect(cleared.tags).toBeNull(); expect(cleared.tags).toBeNull();
expect(cleared.assigneeId).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);
});
});
}); });