diff --git a/README.md b/README.md index 39e0b84..61300f0 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/). | 2 | Frontend shell — Vite PWA, auth-gated routing, register/login pages | ✅ done | | 3 | Todo list + item CRUD API | ✅ done | | 4 | Frontend lists view — list index + create form | ✅ done | -| 5 | Frontend list detail — items UI | planned | +| 5 | Frontend list detail — items UI with drag-and-drop reorder | ✅ done | Registration signs the user in immediately, with the account's email marked unverified (`user.email_verified` is `false` until a future stage adds a @@ -221,6 +221,7 @@ Scoped to a list; the parent list's ownership is checked first (`404` otherwise) |--------|------|---------| | `GET` | `/api/lists/{id}/items` | items, ordered by `position` then `id` | | `POST` | `/api/lists/{id}/items` | add an item | +| `PUT` | `/api/lists/{id}/items/order` | reorder all items in one shot | | `GET` | `/api/lists/{id}/items/{itemId}` | one item | | `PATCH` | `/api/lists/{id}/items/{itemId}` | update `text`, `complete`, and/or `position` | | `DELETE` | `/api/lists/{id}/items/{itemId}` | delete the item (`204`) | @@ -231,6 +232,11 @@ appended after the current highest position). `PATCH` needs at least one field. `position` is a plain sort key the client manages — updating one item never renumbers its siblings. +`PUT …/items/order` takes `{ "item_ids": [3, 1, 2] }` — every item in the list, +each exactly once (`422` otherwise). It rewrites positions to `0..n-1` in one +transaction and returns `{ "items": [ … ] }` in the new order. This is what the +drag-and-drop reorder in the UI calls. + Item representation: ```json diff --git a/src/Http/Controllers/TodoItemController.php b/src/Http/Controllers/TodoItemController.php index 05566ed..937a82c 100644 --- a/src/Http/Controllers/TodoItemController.php +++ b/src/Http/Controllers/TodoItemController.php @@ -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 $args */ diff --git a/src/Repository/TodoItemRepository.php b/src/Repository/TodoItemRepository.php index 7d771d4..5fcb050 100644 --- a/src/Repository/TodoItemRepository.php +++ b/src/Repository/TodoItemRepository.php @@ -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( diff --git a/src/bootstrap.php b/src/bootstrap.php index a9e5847..fcf284d 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -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']); diff --git a/tests/TodoTest.php b/tests/TodoTest.php index 3b2c7f8..fd5e872 100644 --- a/tests/TodoTest.php +++ b/tests/TodoTest.php @@ -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(); diff --git a/web/README.md b/web/README.md index f1391ca..cb030af 100644 --- a/web/README.md +++ b/web/README.md @@ -35,14 +35,23 @@ npm run preview ## Layout ``` -src/main.ts App bootstrap; resolves the stored session before mount -src/router/index.ts Routes + guard (redirects to /login when unauthenticated) -src/stores/auth.ts Pinia store: token in localStorage, register/login/fetchMe -src/stores/lists.ts Pinia store: the user's lists (fetch + create) -src/lib/api.ts fetch wrapper, bearer token, typed ApiError -src/views/ HomeView (lists + create form), LoginView, RegisterView +src/main.ts App bootstrap; resolves the stored session before mount +src/router/index.ts Routes + guard (redirects to /login when unauthenticated) +src/stores/auth.ts Pinia store: token in localStorage, register/login/fetchMe +src/stores/lists.ts Pinia store: the user's lists (fetch + create) +src/stores/items.ts Pinia store: one list's items (CRUD + drag reorder) +src/lib/api.ts fetch wrapper, bearer token, typed ApiError +src/components/TodoItemRow.vue checkbox + editable text + delete, one item +src/views/ HomeView (lists), ListView (items), LoginView, RegisterView ``` +## List detail + +`/lists/:id` shows one list's items. Each row is a checkbox, an inline-editable +text field (saved on blur), a delete button, and a drag handle. Reordering uses +`vuedraggable`; on drop the whole new order is persisted via +`PUT /api/lists/:id/items/order`, and the server response replaces local state. + ## Auth flow - The token from `POST /api/auth/register` or `/login` is kept in `localStorage` diff --git a/web/package-lock.json b/web/package-lock.json index f9f679b..0afc48d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "pinia": "^4.0.3", "vue": "^3.5.41", - "vue-router": "^4.6.4" + "vue-router": "^4.6.4", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@types/node": "^24.13.3", @@ -6134,6 +6135,24 @@ "typescript": ">=5.0.0" } }, + "node_modules/vuedraggable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", + "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==", + "license": "MIT", + "dependencies": { + "sortablejs": "1.14.0" + }, + "peerDependencies": { + "vue": "^3.0.1" + } + }, + "node_modules/vuedraggable/node_modules/sortablejs": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz", + "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/web/package.json b/web/package.json index e3c7d89..4314be2 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,8 @@ "dependencies": { "pinia": "^4.0.3", "vue": "^3.5.41", - "vue-router": "^4.6.4" + "vue-router": "^4.6.4", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@types/node": "^24.13.3", diff --git a/web/src/App.vue b/web/src/App.vue index cb834ad..cd07354 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,15 +1,18 @@ diff --git a/web/src/components/TodoItemRow.vue b/web/src/components/TodoItemRow.vue new file mode 100644 index 0000000..df3694f --- /dev/null +++ b/web/src/components/TodoItemRow.vue @@ -0,0 +1,56 @@ + + + diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 49444dd..3f8f102 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -10,6 +10,12 @@ const router = createRouter({ component: () => import('../views/HomeView.vue'), meta: { requiresAuth: true }, }, + { + path: '/lists/:id(\\d+)', + name: 'list', + component: () => import('../views/ListView.vue'), + meta: { requiresAuth: true }, + }, { path: '/login', name: 'login', diff --git a/web/src/stores/items.ts b/web/src/stores/items.ts new file mode 100644 index 0000000..88ce41a --- /dev/null +++ b/web/src/stores/items.ts @@ -0,0 +1,88 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiRequest } from '../lib/api' +import type { TodoItem } from '../types' + +type ItemPatch = Partial> + +export const useItemsStore = defineStore('items', () => { + // Held in list order (by position); mutated in place by drag-and-drop. + const items = ref([]) + const listId = ref(null) + const loading = ref(false) + const loaded = ref(false) + + const completedCount = () => items.value.filter((i) => i.complete).length + + async function load(id: number): Promise { + listId.value = id + loaded.value = false + loading.value = true + try { + const { items: fetched } = await apiRequest<{ items: TodoItem[] }>(`/lists/${id}/items`, { + auth: true, + }) + items.value = fetched + loaded.value = true + } finally { + loading.value = false + } + } + + async function add(text: string): Promise { + const { item } = await apiRequest<{ item: TodoItem }>(`/lists/${listId.value}/items`, { + method: 'POST', + auth: true, + body: { text }, + }) + // The API appends the item, so the end of the array is its correct place. + items.value.push(item) + } + + async function patch(item: TodoItem, fields: ItemPatch): Promise { + const { item: updated } = await apiRequest<{ item: TodoItem }>( + `/lists/${listId.value}/items/${item.id}`, + { method: 'PATCH', auth: true, body: fields }, + ) + const i = items.value.findIndex((x) => x.id === updated.id) + if (i !== -1) items.value[i] = updated + } + + const setComplete = (item: TodoItem, complete: boolean) => patch(item, { complete }) + const setText = (item: TodoItem, text: string) => patch(item, { text }) + + async function remove(item: TodoItem): Promise { + await apiRequest(`/lists/${listId.value}/items/${item.id}`, { method: 'DELETE', auth: true }) + items.value = items.value.filter((i) => i.id !== item.id) + } + + /** Persist the current array order (call after a drag ends). */ + async function persistOrder(): Promise { + const { items: fresh } = await apiRequest<{ items: TodoItem[] }>( + `/lists/${listId.value}/items/order`, + { method: 'PUT', auth: true, body: { item_ids: items.value.map((i) => i.id) } }, + ) + items.value = fresh + } + + function reset(): void { + items.value = [] + listId.value = null + loaded.value = false + } + + return { + items, + listId, + loading, + loaded, + completedCount, + load, + add, + setComplete, + setText, + remove, + persistOrder, + reset, + } +}) diff --git a/web/src/style.css b/web/src/style.css index b581856..45caf1f 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -123,7 +123,18 @@ h1 { .lists__item { border: 1px solid var(--border); border-radius: 8px; +} + +.lists__link { + display: block; padding: 0.75rem 0.9rem; + color: inherit; + text-decoration: none; + border-radius: 8px; +} + +.lists__link:hover { + background: var(--bg); } .lists__head { @@ -138,10 +149,90 @@ h1 { word-break: break-word; } -.lists__item .muted { +.lists__link .muted { margin: 0.35rem 0 0; } +/* --- list detail: items ------------------------------------------------- */ + +.items { + list-style: none; + margin: 1rem 0 0; + padding: 0; + display: grid; + gap: 0.4rem; +} + +.item { + display: flex; + align-items: center; + gap: 0.5rem; + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.4rem 0.55rem; + background: var(--surface); +} + +.item--ghost { + opacity: 0.5; +} + +.item__handle { + cursor: grab; + color: var(--muted); + user-select: none; + padding: 0 0.15rem; + line-height: 1; +} + +.item__check { + flex: none; + width: 1.1rem; + height: 1.1rem; +} + +.item__text { + flex: 1; + min-width: 0; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--text); + font-size: 1rem; + padding: 0.3rem 0.4rem; +} + +.item__text:hover { + border-color: var(--border); +} + +.item__text:focus { + outline: none; + border-color: var(--accent); + background: var(--bg); +} + +.item--done .item__text { + text-decoration: line-through; + color: var(--muted); +} + +.item__delete { + flex: none; + border: none; + background: none; + color: var(--muted); + cursor: pointer; + font-size: 1rem; + padding: 0.2rem 0.4rem; + border-radius: 6px; +} + +.item__delete:hover { + color: var(--error); + background: var(--bg); +} + .form { display: grid; gap: 1rem; diff --git a/web/src/types.ts b/web/src/types.ts index 7b4dbb5..51a1288 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -23,6 +23,16 @@ export interface TodoList { updated_at: string } +export interface TodoItem { + id: number + list_id: number + text: string + complete: boolean + position: number + created_at: string + updated_at: string +} + /** Shape of every error body returned by the API. */ export interface ApiErrorBody { error: { diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue index ff0beee..cd4f6ce 100644 --- a/web/src/views/HomeView.vue +++ b/web/src/views/HomeView.vue @@ -65,11 +65,13 @@ async function onCreate() {
  • -
    - {{ list.title }} - {{ list.completed_count }} / {{ list.item_count }} done -
    -

    {{ list.description }}

    + +
    + {{ list.title }} + {{ list.completed_count }} / {{ list.item_count }} done +
    +

    {{ list.description }}

    +
diff --git a/web/src/views/ListView.vue b/web/src/views/ListView.vue new file mode 100644 index 0000000..a04ef63 --- /dev/null +++ b/web/src/views/ListView.vue @@ -0,0 +1,125 @@ + + +