Add stage 5: list detail page with items UI and drag reorder

API: new PUT /api/lists/{id}/items/order takes the full ordered id set and
rewrites positions 0..n-1 in a transaction (422 unless the set matches the
list exactly). TodoItemRepository gains idsForList() and reorder().

Frontend: lists on the home page are now links to /lists/:id (ListView).
ListView shows the list title, a "M of N done" summary, and each item as a
drag handle + checkbox + inline-editable text (saved on blur) + delete
button, with a create-item form at the bottom. Drag-and-drop uses
vuedraggable; on drop the whole order is persisted via the new endpoint and
the response replaces local state, with a resync-on-error fallback. New
items store; items store is also reset on logout.

Tests: reorder happy path, incomplete-set rejection, owner scoping. Backend
suite: 23 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 18:59:51 +01:00
co-authored by Claude Sonnet 5
parent dd2ab5b7d3
commit b506b83b1e
16 changed files with 568 additions and 15 deletions
+62
View File
@@ -143,6 +143,68 @@ final class TodoTest extends ApiTestCase
self::assertArrayHasKey('text', $this->decode($bad)['error']['details']);
}
public function test_items_can_be_reordered_in_bulk(): void
{
$auth = $this->authHeader();
$listId = $this->decode(
$this->request('POST', '/api/lists', ['title' => 'Reorder'], $auth),
)['list']['id'];
$ids = [];
foreach (['A', 'B', 'C'] as $text) {
$ids[$text] = $this->decode(
$this->request('POST', "/api/lists/{$listId}/items", ['text' => $text], $auth),
)['item']['id'];
}
$response = $this->request('PUT', "/api/lists/{$listId}/items/order", [
'item_ids' => [$ids['C'], $ids['A'], $ids['B']],
], $auth);
self::assertSame(200, $response->getStatusCode());
$items = $this->decode($response)['items'];
self::assertSame(['C', 'A', 'B'], array_column($items, 'text'));
self::assertSame([0, 1, 2], array_column($items, 'position'));
// Order persists on a fresh read.
$reread = $this->decode($this->request('GET', "/api/lists/{$listId}/items", null, $auth))['items'];
self::assertSame(['C', 'A', 'B'], array_column($reread, 'text'));
}
public function test_reorder_rejects_an_incomplete_id_set(): void
{
$auth = $this->authHeader();
$listId = $this->decode(
$this->request('POST', '/api/lists', ['title' => 'Reorder'], $auth),
)['list']['id'];
$first = $this->decode(
$this->request('POST', "/api/lists/{$listId}/items", ['text' => 'one'], $auth),
)['item']['id'];
$this->request('POST', "/api/lists/{$listId}/items", ['text' => 'two'], $auth);
$response = $this->request('PUT', "/api/lists/{$listId}/items/order", [
'item_ids' => [$first],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_is_scoped_to_the_owner(): void
{
$owner = $this->authHeader('ro@example.com');
$other = $this->authHeader('rx@example.com');
$listId = $this->decode(
$this->request('POST', '/api/lists', ['title' => 'Mine'], $owner),
)['list']['id'];
$itemId = $this->decode(
$this->request('POST', "/api/lists/{$listId}/items", ['text' => 'x'], $owner),
)['item']['id'];
self::assertSame(404, $this->request('PUT', "/api/lists/{$listId}/items/order", [
'item_ids' => [$itemId],
], $other)->getStatusCode());
}
public function test_deleting_a_list_cascades_to_its_items(): void
{
$auth = $this->authHeader();