Add per-project card statuses and a kanban board

Statuses
- Migration 006: card_statuses table (project-scoped) and cards.status_id, a
  nullable FK with ON DELETE SET NULL. Every new project is seeded with
  "To do" / "Doing" / "Done"; GET /api/projects/{id}/statuses lists them.
- New cards have no status -- they sit in an "inbox" until moved.

Project view
- Full-width and tabbed: "All tasks" (a flat list, sorted by name
  case-insensitively) and "Kanban" (Inbox plus one column per status).
- Drag a card within or between columns to reorder / restatus; the Inbox
  column has its own name + Add form.

Ordering
- Migration 007: `position` is now a dense 0..n-1 rank within a
  (project_id, status_id) column, not a project-wide order. New composite
  index idx_cards_project_status_position; existing rows re-ranked.
- PUT /api/projects/{id}/cards/order takes { status_id, card_ids } and sets one
  column's contents and order, re-parenting moved-in cards and re-packing their
  source column in a single transaction. PATCH status_id appends the card to the
  end of the destination column.

58 phpunit tests pass; the frontend type-checks and builds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 13:37:17 +01:00
co-authored by Claude Sonnet 5
parent d9db4a3a30
commit c47c800d01
21 changed files with 1389 additions and 239 deletions
+256
View File
@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace Tests;
/**
* `position` is a dense rank within a column — the cards sharing a
* (project, status). PUT /api/projects/{id}/cards/order sets one column's
* contents and order.
*/
final class CardOrderTest extends ApiTestCase
{
/** @param array<string, string> $auth */
private function newProject(array $auth, string $title = 'Board'): int
{
return $this->decode($this->request('POST', '/api/projects', ['title' => $title], $auth))['project']['id'];
}
/** @param array<string, string> $auth @return array<int, mixed> */
private function statuses(int $projectId, array $auth): array
{
return $this->decode(
$this->request('GET', "/api/projects/{$projectId}/statuses", null, $auth),
)['statuses'];
}
/** @param array<string, string> $auth */
private function addCard(int $projectId, string $text, array $auth): int
{
return $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => $text], $auth),
)['card']['id'];
}
/** @param array<string, string> $auth @return list<array{text: string, status_id: int|null, position: int}> */
private function cards(int $projectId, array $auth): array
{
return array_map(
static fn (array $c): array => [
'text' => $c['text'],
'status_id' => $c['status_id'],
'position' => $c['position'],
],
$this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'],
);
}
public function test_new_cards_are_ranked_densely_within_the_inbox(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
foreach (['A', 'B', 'C'] as $text) {
$this->addCard($projectId, $text, $auth);
}
self::assertSame(
[['text' => 'A', 'status_id' => null, 'position' => 0],
['text' => 'B', 'status_id' => null, 'position' => 1],
['text' => 'C', 'status_id' => null, 'position' => 2]],
$this->cards($projectId, $auth),
);
}
public function test_reorder_within_the_inbox_column(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$ids = [];
foreach (['A', 'B', 'C'] as $text) {
$ids[$text] = $this->addCard($projectId, $text, $auth);
}
$response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => null,
'card_ids' => [$ids['C'], $ids['A'], $ids['B']],
], $auth);
self::assertSame(200, $response->getStatusCode());
self::assertSame(['C', 'A', 'B'], array_column($this->decode($response)['cards'], 'text'));
self::assertSame([0, 1, 2], array_column($this->cards($projectId, $auth), 'position'));
}
public function test_ordering_a_status_column_moves_cards_into_it_and_repacks_the_inbox(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$ids = [];
foreach (['A', 'B', 'C'] as $text) {
$ids[$text] = $this->addCard($projectId, $text, $auth);
}
// Move B and C into "To do" (C first), leaving A alone in the inbox.
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$ids['C'], $ids['B']],
], $auth);
$byText = [];
foreach ($this->cards($projectId, $auth) as $c) {
$byText[$c['text']] = $c;
}
self::assertSame(['status_id' => null, 'position' => 0], ['status_id' => $byText['A']['status_id'], 'position' => $byText['A']['position']]);
self::assertSame(['status_id' => $todo, 'position' => 0], ['status_id' => $byText['C']['status_id'], 'position' => $byText['C']['position']]);
self::assertSame(['status_id' => $todo, 'position' => 1], ['status_id' => $byText['B']['status_id'], 'position' => $byText['B']['position']]);
}
public function test_moving_a_card_out_of_the_inbox_repacks_the_survivors(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$ids = [];
foreach (['A', 'B', 'C'] as $text) {
$ids[$text] = $this->addCard($projectId, $text, $auth); // inbox 0,1,2
}
// Pull the middle card into "To do".
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$ids['B']],
], $auth);
$inbox = array_values(array_filter($this->cards($projectId, $auth), static fn ($c) => $c['status_id'] === null));
self::assertSame(
[['text' => 'A', 'status_id' => null, 'position' => 0],
['text' => 'C', 'status_id' => null, 'position' => 1]],
$inbox,
);
}
public function test_a_new_inbox_card_lands_after_the_repacked_survivors(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$a = $this->addCard($projectId, 'A', $auth);
$b = $this->addCard($projectId, 'B', $auth); // inbox: A@0, B@1
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$a],
], $auth); // inbox now: B@0
$newId = $this->addCard($projectId, 'C', $auth);
$byId = [];
foreach ($this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'] as $c) {
$byId[$c['id']] = $c;
}
self::assertSame(0, $byId[$b]['position']);
self::assertSame(1, $byId[$newId]['position']);
self::assertNull($byId[$newId]['status_id']);
}
public function test_patch_status_appends_the_card_to_the_destination_column(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$first = $this->addCard($projectId, 'first', $auth);
$second = $this->addCard($projectId, 'second', $auth);
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$first}", ['status_id' => $todo], $auth);
$moved = $this->decode(
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$second}", ['status_id' => $todo], $auth),
)['card'];
self::assertSame($todo, $moved['status_id']);
self::assertSame(1, $moved['position']); // after `first`, which took slot 0
}
public function test_reorder_rejects_a_foreign_status(): void
{
$auth = $this->authHeader();
$mine = $this->newProject($auth, 'Mine');
$other = $this->newProject($auth, 'Other');
$foreignStatus = $this->statuses($other, $auth)[0]['id'];
$card = $this->addCard($mine, 'x', $auth);
$response = $this->request('PUT', "/api/projects/{$mine}/cards/order", [
'status_id' => $foreignStatus,
'card_ids' => [$card],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_rejects_a_card_from_another_project(): void
{
$auth = $this->authHeader();
$mine = $this->newProject($auth, 'Mine');
$other = $this->newProject($auth, 'Other');
$foreignCard = $this->addCard($other, 'x', $auth);
$response = $this->request('PUT', "/api/projects/{$mine}/cards/order", [
'status_id' => null,
'card_ids' => [$foreignCard],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_must_list_every_card_already_in_the_target_column(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$a = $this->addCard($projectId, 'A', $auth);
$b = $this->addCard($projectId, 'B', $auth);
// Put both in "To do".
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$a, $b],
], $auth);
// Now try to reorder "To do" mentioning only one of them.
$response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$b],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_rejects_duplicate_ids(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$a = $this->addCard($projectId, 'A', $auth);
$response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => null,
'card_ids' => [$a, $a],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_is_scoped_to_the_owner(): void
{
$owner = $this->authHeader('owner@example.com');
$other = $this->authHeader('other@example.com');
$projectId = $this->newProject($owner);
$card = $this->addCard($projectId, 'x', $owner);
self::assertSame(404, $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => null,
'card_ids' => [$card],
], $other)->getStatusCode());
}
}