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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user