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/ログを出さない。
This commit is contained in:
@ -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