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 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 00:10:52 +01:00
co-authored by Claude Sonnet 5
parent e598d4ef57
commit 19ccb8f881
5 changed files with 111 additions and 8 deletions
+6 -3
View File
@@ -326,15 +326,18 @@ own id, rather than nested under a project:
| Method | Path | Purpose | | Method | Path | Purpose |
|--------|------|---------| |--------|------|---------|
| `GET` | `/api/projects/{id}/cards` | a project's cards, grouped by status then `position` | | `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 | | `GET` | `/api/inbox/cards` | the caller's inbox |
| `POST` | `/api/inbox/cards` | add a card to the inbox | | `POST` | `/api/inbox/cards` | add a card to the inbox |
| `GET` \| `PATCH` \| `DELETE` | `/api/cards/{cardId}` | one card, owner-scoped (`404` otherwise) | | `GET` \| `PATCH` \| `DELETE` | `/api/cards/{cardId}` | one card, owner-scoped (`404` otherwise) |
| `PUT` | `/api/cards/order` | set the order/contents of one column | | `PUT` | `/api/cards/order` | set the order/contents of one column |
Create body (either creation route): `text` (required, 11000 chars), Create body (either creation route): `text` (required, 11000 chars),
`complete` (optional bool, default `false`). `PATCH` accepts `text` and/or `complete` (optional bool, default `false`). The project route also takes an
`complete` only — moving a card is done via the order route below, not PATCH. 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 **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 that share an `(owner, project, status)`. The inbox is its own column, per
+9 -3
View File
@@ -45,8 +45,9 @@ final class CardController extends ProjectScopedController
/** /**
* POST /api/projects/{projectId}/cards * POST /api/projects/{projectId}/cards
* *
* Creates the card directly in the project, in its first status (a * Creates the card directly in the project, at the end of the given
* project card always has one -- see the invariant on the `cards` table). * 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 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); $text = $validator->requiredString('text', self::TEXT_MAX);
$complete = $validator->optionalBool('complete') ?? false; $complete = $validator->optionalBool('complete') ?? false;
$position = $validator->optionalInt('position', 0); $position = $validator->optionalInt('position', 0);
$statusId = $validator->optionalInt('status_id', 1);
$validator->assert(); $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( $card = $this->cards->createInProject(
$this->user($request)['id'], $this->user($request)['id'],
$projectId, $projectId,
$this->firstStatusId($projectId), $statusId ?? $this->firstStatusId($projectId),
$text, $text,
$complete, $complete,
$position, $position,
+59
View File
@@ -119,6 +119,65 @@ final class CardOrderTest extends ApiTestCase
self::assertSame([null, null, null], array_column($this->inbox($auth), 'project_id')); 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 -------------------------------------- // --- reordering within a column --------------------------------------
public function test_reorder_within_the_inbox(): void public function test_reorder_within_the_inbox(): void
+4 -2
View File
@@ -32,11 +32,13 @@ export const useCardsStore = defineStore('cards', () => {
} }
} }
async function add(text: string): Promise<void> { /** 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<void> {
const { card } = await apiRequest<{ card: Card }>(`/projects/${projectId.value}/cards`, { const { card } = await apiRequest<{ card: Card }>(`/projects/${projectId.value}/cards`, {
method: 'POST', method: 'POST',
auth: true, 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. // The API appends the card, so the end of the array is its correct place.
cards.value.push(card) cards.value.push(card)
+33
View File
@@ -134,6 +134,24 @@ async function onCreate() {
submitting.value = false 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<Record<number, string>>({})
const addingToStatusId = ref<number | null>(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
}
}
</script> </script>
<template> <template>
@@ -223,6 +241,21 @@ async function onCreate() {
<KanbanCard :card="element" /> <KanbanCard :card="element" />
</template> </template>
</draggable> </draggable>
<form class="kanban__new" @submit.prevent="onCreateInColumn(column)">
<input
v-model="newColumnCardText[column.statusId]"
class="field field--compact"
type="text"
maxlength="1000"
required
placeholder="New card"
:aria-label="`New card in ${column.title}`"
/>
<button type="submit" :disabled="addingToStatusId === column.statusId">
{{ addingToStatusId === column.statusId ? 'Adding…' : 'Add' }}
</button>
</form>
</section> </section>
</div> </div>
</div> </div>