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>
257 lines
9.5 KiB
PHP
257 lines
9.5 KiB
PHP
<?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());
|
|
}
|
|
}
|