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
@@ -108,6 +108,37 @@ final class TodoItemController extends Controller
return $response->withStatus(204);
}
/**
* PUT /api/lists/{listId}/items/order
*
* Body: { "item_ids": [3, 1, 2] } — every item in the list, exactly once,
* in the desired order. Positions are rewritten to 0..n-1.
*/
public function reorder(Request $request, Response $response, array $args): Response
{
$listId = $this->requireOwnedListId($request, $args);
$order = $this->body($request)['item_ids'] ?? null;
if (!is_array($order) || array_filter($order, static fn ($id) => !is_int($id)) !== []) {
throw new ApiException('item_ids must be an array of item IDs.', 422);
}
/** @var int[] $order */
$expected = $this->items->idsForList($listId);
$given = $order;
sort($given);
sort($expected);
if ($given !== $expected) {
throw new ApiException('item_ids must contain every item in the list exactly once.', 422);
}
$items = $this->items->reorder($listId, $order);
$this->lists->touch($listId);
return $this->json($response, ['items' => array_map($this->present(...), $items)]);
}
/**
* @param array<string, string> $args
*/