From 19ccb8f88172746ad49daba7634b0dc44bad1d71 Mon Sep 17 00:00:00 2001 From: Aneurin Barker Snook Date: Sat, 5 Sep 2026 00:10:52 +0100 Subject: [PATCH] Add a "new card" form to each kanban column Each column gets its own form at the bottom (styled like the sidebar inbox's), creating the card directly in that status, appended after its existing cards. - API: POST /projects/{id}/cards takes an optional status_id, which must belong to the project (422 otherwise); omitted, it still defaults to the project's first status as before. Position is already "end of that status" for free -- createInProject() already ranks by (owner, project, status). - cards store: add() takes an optional statusId, forwarded as status_id when given. - ProjectView: one draft string per status (keyed by status id) so typing in one column doesn't touch another's, mirroring the per-status independence the columns already have for reordering. Co-Authored-By: Claude Sonnet 5 --- README.md | 9 ++-- src/Http/Controllers/CardController.php | 12 +++-- tests/CardOrderTest.php | 59 +++++++++++++++++++++++++ web/src/stores/cards.ts | 6 ++- web/src/views/ProjectView.vue | 33 ++++++++++++++ 5 files changed, 111 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5857f05..74d290e 100644 --- a/README.md +++ b/README.md @@ -326,15 +326,18 @@ own id, rather than nested under a project: | Method | Path | Purpose | |--------|------|---------| | `GET` | `/api/projects/{id}/cards` | a project's cards, grouped by status then `position` | -| `POST` | `/api/projects/{id}/cards` | add a card directly to the project (its first status) | +| `POST` | `/api/projects/{id}/cards` | add a card directly to the project | | `GET` | `/api/inbox/cards` | the caller's inbox | | `POST` | `/api/inbox/cards` | add a card to the inbox | | `GET` \| `PATCH` \| `DELETE` | `/api/cards/{cardId}` | one card, owner-scoped (`404` otherwise) | | `PUT` | `/api/cards/order` | set the order/contents of one column | Create body (either creation route): `text` (required, 1–1000 chars), -`complete` (optional bool, default `false`). `PATCH` accepts `text` and/or -`complete` only — moving a card is done via the order route below, not PATCH. +`complete` (optional bool, default `false`). The project route also takes an +optional `status_id`, appending the card to the end of that status (must +belong to the project, else `422`) — omitted, it goes in the project's first +status instead. `PATCH` accepts `text` and/or `complete` only — moving a card +is done via the order route below, not PATCH. **Ordering.** `position` is a dense `0..n-1` rank *within a column* — the cards that share an `(owner, project, status)`. The inbox is its own column, per diff --git a/src/Http/Controllers/CardController.php b/src/Http/Controllers/CardController.php index b1c032c..c98f290 100644 --- a/src/Http/Controllers/CardController.php +++ b/src/Http/Controllers/CardController.php @@ -45,8 +45,9 @@ final class CardController extends ProjectScopedController /** * POST /api/projects/{projectId}/cards * - * Creates the card directly in the project, in its first status (a - * project card always has one -- see the invariant on the `cards` table). + * Creates the card directly in the project, at the end of the given + * status -- or the project's first status if none is given (a project + * card always has one -- see the invariant on the `cards` table). */ public function store(Request $request, Response $response, array $args): Response { @@ -56,12 +57,17 @@ final class CardController extends ProjectScopedController $text = $validator->requiredString('text', self::TEXT_MAX); $complete = $validator->optionalBool('complete') ?? false; $position = $validator->optionalInt('position', 0); + $statusId = $validator->optionalInt('status_id', 1); $validator->assert(); + if ($statusId !== null && $this->statuses->findInProject($statusId, $projectId) === null) { + throw new ApiException('That status does not belong to this project.', 422); + } + $card = $this->cards->createInProject( $this->user($request)['id'], $projectId, - $this->firstStatusId($projectId), + $statusId ?? $this->firstStatusId($projectId), $text, $complete, $position, diff --git a/tests/CardOrderTest.php b/tests/CardOrderTest.php index 7a028ce..fd9516c 100644 --- a/tests/CardOrderTest.php +++ b/tests/CardOrderTest.php @@ -119,6 +119,65 @@ final class CardOrderTest extends ApiTestCase self::assertSame([null, null, null], array_column($this->inbox($auth), 'project_id')); } + public function test_creating_a_project_card_in_a_given_status(): void + { + $auth = $this->authHeader(); + $projectId = $this->newProject($auth); + $doing = $this->statuses($projectId, $auth)[1]['id']; + $this->addToProject($projectId, 'Already doing this', $auth); // lands in "To do", not "Doing" + + $response = $this->request( + 'POST', + "/api/projects/{$projectId}/cards", + ['text' => 'Start this', 'status_id' => $doing], + $auth, + ); + + self::assertSame(201, $response->getStatusCode()); + $card = $this->decode($response)['card']; + self::assertSame($doing, $card['status_id']); + self::assertSame(0, $card['position']); // first (and only) card in "Doing" so far + } + + public function test_creating_a_project_card_appends_to_the_end_of_its_status(): void + { + $auth = $this->authHeader(); + $projectId = $this->newProject($auth); + $doing = $this->statuses($projectId, $auth)[1]['id']; + $first = $this->request( + 'POST', + "/api/projects/{$projectId}/cards", + ['text' => 'First', 'status_id' => $doing], + $auth, + ); + + $second = $this->request( + 'POST', + "/api/projects/{$projectId}/cards", + ['text' => 'Second', 'status_id' => $doing], + $auth, + ); + + self::assertSame(0, $this->decode($first)['card']['position']); + self::assertSame(1, $this->decode($second)['card']['position']); + } + + public function test_creating_a_project_card_rejects_a_status_from_another_project(): void + { + $auth = $this->authHeader(); + $projectId = $this->newProject($auth, 'A'); + $otherStatus = $this->statuses($this->newProject($auth, 'B'), $auth)[0]['id']; + + $response = $this->request( + 'POST', + "/api/projects/{$projectId}/cards", + ['text' => 'x', 'status_id' => $otherStatus], + $auth, + ); + + self::assertSame(422, $response->getStatusCode()); + } + // --- reordering within a column -------------------------------------- public function test_reorder_within_the_inbox(): void diff --git a/web/src/stores/cards.ts b/web/src/stores/cards.ts index b632894..49fe459 100644 --- a/web/src/stores/cards.ts +++ b/web/src/stores/cards.ts @@ -32,11 +32,13 @@ export const useCardsStore = defineStore('cards', () => { } } - async function add(text: string): Promise { + /** Adds to the project's first status, unless a particular one is given + * (e.g. a kanban column's own "new card" form) -- either way, at the end. */ + async function add(text: string, statusId?: number): Promise { const { card } = await apiRequest<{ card: Card }>(`/projects/${projectId.value}/cards`, { method: 'POST', auth: true, - body: { text }, + body: statusId === undefined ? { text } : { text, status_id: statusId }, }) // The API appends the card, so the end of the array is its correct place. cards.value.push(card) diff --git a/web/src/views/ProjectView.vue b/web/src/views/ProjectView.vue index d670d07..956e427 100644 --- a/web/src/views/ProjectView.vue +++ b/web/src/views/ProjectView.vue @@ -134,6 +134,24 @@ async function onCreate() { submitting.value = false } } + +// --- add a card directly into one kanban column -- one draft per status, so +// typing in one column's form doesn't touch another's. +const newColumnCardText = ref>({}) +const addingToStatusId = ref(null) + +async function onCreateInColumn(column: Column) { + addingToStatusId.value = column.statusId + actionError.value = null + try { + await cards.add(newColumnCardText.value[column.statusId] ?? '', column.statusId) + newColumnCardText.value[column.statusId] = '' + } catch (e) { + actionError.value = e instanceof ApiError ? e.message : 'Could not add the card.' + } finally { + addingToStatusId.value = null + } +} + +
+ + +