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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user