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:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -106,6 +106,49 @@ final class TodoItemRepository
|
||||
$this->pdo->prepare('DELETE FROM todo_items WHERE id = :id')->execute(['id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs of every item in the list, in current position order.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
public function idsForList(int $listId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id FROM todo_items WHERE list_id = :list ORDER BY position ASC, id ASC'
|
||||
);
|
||||
$stmt->execute(['list' => $listId]);
|
||||
|
||||
return array_map(intval(...), $stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign positions 0..n-1 to the given items in one transaction.
|
||||
*
|
||||
* @param int[] $orderedIds every item id in the list, exactly once
|
||||
* @return TodoItemRow[] the list's items in their new order
|
||||
*/
|
||||
public function reorder(int $listId, array $orderedIds): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE todo_items SET position = :position,
|
||||
updated_at = ' . "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')" . '
|
||||
WHERE id = :id AND list_id = :list'
|
||||
);
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
try {
|
||||
foreach (array_values($orderedIds) as $position => $id) {
|
||||
$stmt->execute(['position' => $position, 'id' => $id, 'list' => $listId]);
|
||||
}
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this->allForList($listId);
|
||||
}
|
||||
|
||||
private function nextPosition(int $listId): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
|
||||
@@ -70,6 +70,7 @@ $app->group('/api', function (RouteCollectorProxy $group) use (
|
||||
|
||||
$lists->get('/{listId:[0-9]+}/items', [$itemController, 'index']);
|
||||
$lists->post('/{listId:[0-9]+}/items', [$itemController, 'store']);
|
||||
$lists->put('/{listId:[0-9]+}/items/order', [$itemController, 'reorder']);
|
||||
$lists->get('/{listId:[0-9]+}/items/{itemId:[0-9]+}', [$itemController, 'show']);
|
||||
$lists->patch('/{listId:[0-9]+}/items/{itemId:[0-9]+}', [$itemController, 'update']);
|
||||
$lists->delete('/{listId:[0-9]+}/items/{itemId:[0-9]+}', [$itemController, 'destroy']);
|
||||
|
||||
Reference in New Issue
Block a user