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 + } +} + +
+ + +