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:
@@ -6,6 +6,7 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Exception\ApiException;
|
||||
use App\Repository\CardRepository;
|
||||
use App\Repository\CardStatusRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Support\Validator;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
@@ -22,6 +23,7 @@ final class CardController extends Controller
|
||||
public function __construct(
|
||||
private readonly ProjectRepository $projects,
|
||||
private readonly CardRepository $cards,
|
||||
private readonly CardStatusRepository $statuses,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -82,11 +84,17 @@ final class CardController extends Controller
|
||||
if ($validator->has('complete')) {
|
||||
$fields['complete'] = $validator->optionalBool('complete');
|
||||
}
|
||||
if ($validator->has('position')) {
|
||||
$fields['position'] = $validator->optionalInt('position', 0);
|
||||
if ($validator->has('status_id')) {
|
||||
$statusId = $validator->nullableInt('status_id', 1);
|
||||
if ($statusId !== null && !$validator->failed()
|
||||
&& $this->statuses->findInProject($statusId, $projectId) === null
|
||||
) {
|
||||
$validator->add('status_id', 'That status does not belong to this project.');
|
||||
}
|
||||
$fields['status_id'] = $statusId;
|
||||
}
|
||||
if ($fields === [] && !$validator->failed()) {
|
||||
$validator->add('text', 'Provide at least one of: text, complete, position.');
|
||||
$validator->add('text', 'Provide at least one of: text, complete, status_id.');
|
||||
}
|
||||
$validator->assert();
|
||||
|
||||
@@ -111,29 +119,45 @@ final class CardController extends Controller
|
||||
/**
|
||||
* PUT /api/projects/{projectId}/cards/order
|
||||
*
|
||||
* Body: { "card_ids": [3, 1, 2] } — every card in the project, exactly once,
|
||||
* in the desired order. Positions are rewritten to 0..n-1.
|
||||
* Sets the contents and order of one status column.
|
||||
*
|
||||
* Body: { "status_id": 2 | null, "card_ids": [3, 1, 2] } — the cards that
|
||||
* should make up that column, in order. Positions are rewritten to 0..n-1;
|
||||
* any card moved in from another column is re-parented and its old column
|
||||
* re-packed. `status_id` is omitted or null for the inbox.
|
||||
*/
|
||||
public function reorder(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$projectId = $this->requireOwnedProjectId($request, $args);
|
||||
$body = $this->body($request);
|
||||
|
||||
$order = $this->body($request)['card_ids'] ?? null;
|
||||
$statusId = null;
|
||||
if (array_key_exists('status_id', $body) && $body['status_id'] !== null) {
|
||||
if (!is_int($body['status_id'])) {
|
||||
throw new ApiException('status_id must be a status ID or null.', 422);
|
||||
}
|
||||
$statusId = $body['status_id'];
|
||||
if ($this->statuses->findInProject($statusId, $projectId) === null) {
|
||||
throw new ApiException('That status does not belong to this project.', 422);
|
||||
}
|
||||
}
|
||||
|
||||
$order = $body['card_ids'] ?? null;
|
||||
if (!is_array($order) || array_filter($order, static fn ($id) => !is_int($id)) !== []) {
|
||||
throw new ApiException('card_ids must be an array of card IDs.', 422);
|
||||
}
|
||||
|
||||
/** @var int[] $order */
|
||||
$expected = $this->cards->idsForProject($projectId);
|
||||
$given = $order;
|
||||
sort($given);
|
||||
sort($expected);
|
||||
|
||||
if ($given !== $expected) {
|
||||
throw new ApiException('card_ids must contain every card in the project exactly once.', 422);
|
||||
if (count($order) !== count(array_unique($order))) {
|
||||
throw new ApiException('card_ids must not contain duplicates.', 422);
|
||||
}
|
||||
if (array_diff($order, $this->cards->idsForProject($projectId)) !== []) {
|
||||
throw new ApiException('Every card_id must be a card in this project.', 422);
|
||||
}
|
||||
if (array_diff($this->cards->idsInColumn($projectId, $statusId), $order) !== []) {
|
||||
throw new ApiException('card_ids must include every card already in this column.', 422);
|
||||
}
|
||||
|
||||
$cards = $this->cards->reorder($projectId, $order);
|
||||
$cards = $this->cards->orderColumn($projectId, $statusId, $order);
|
||||
$this->projects->touch($projectId);
|
||||
|
||||
return $this->json($response, ['cards' => array_map($this->present(...), $cards)]);
|
||||
@@ -155,7 +179,7 @@ final class CardController extends Controller
|
||||
|
||||
/**
|
||||
* @param array<string, string> $args
|
||||
* @return array{id: int, project_id: int, text: string, complete: bool, position: int, created_at: string, updated_at: string}
|
||||
* @return array{id: int, project_id: int, text: string, complete: bool, position: int, status_id: int|null, status: array{id: int, name: string}|null, created_at: string, updated_at: string}
|
||||
*/
|
||||
private function requireCard(int $projectId, array $args): array
|
||||
{
|
||||
@@ -169,7 +193,7 @@ final class CardController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: int, project_id: int, text: string, complete: bool, position: int, created_at: string, updated_at: string} $card
|
||||
* @param array{id: int, project_id: int, text: string, complete: bool, position: int, status_id: int|null, status: array{id: int, name: string}|null, created_at: string, updated_at: string} $card
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function present(array $card): array
|
||||
@@ -180,6 +204,8 @@ final class CardController extends Controller
|
||||
'text' => $card['text'],
|
||||
'complete' => $card['complete'],
|
||||
'position' => $card['position'],
|
||||
'status_id' => $card['status_id'],
|
||||
'status' => $card['status'],
|
||||
'created_at' => $card['created_at'],
|
||||
'updated_at' => $card['updated_at'],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exception\ApiException;
|
||||
use App\Repository\CardStatusRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
|
||||
/**
|
||||
* Read-only listing of a project's card statuses. Statuses are seeded when the
|
||||
* project is created; there is no create/update/delete yet.
|
||||
*/
|
||||
final class CardStatusController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectRepository $projects,
|
||||
private readonly CardStatusRepository $statuses,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/projects/{projectId}/statuses
|
||||
*/
|
||||
public function index(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$projectId = $this->requireOwnedProjectId($request, $args);
|
||||
|
||||
return $this->json($response, [
|
||||
'statuses' => array_map($this->present(...), $this->statuses->allForProject($projectId)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $args
|
||||
*/
|
||||
private function requireOwnedProjectId(Request $request, array $args): int
|
||||
{
|
||||
$project = $this->projects->findOwnedBy((int) $args['projectId'], $this->user($request)['id']);
|
||||
|
||||
if ($project === null) {
|
||||
throw new ApiException('Project not found.', 404);
|
||||
}
|
||||
|
||||
return $project['id'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: int, project_id: int, name: string, position: int, created_at: string, updated_at: string} $status
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function present(array $status): array
|
||||
{
|
||||
return [
|
||||
'id' => $status['id'],
|
||||
'project_id' => $status['project_id'],
|
||||
'name' => $status['name'],
|
||||
'position' => $status['position'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exception\ApiException;
|
||||
use App\Repository\CardStatusRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Support\Validator;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
@@ -20,8 +21,10 @@ final class ProjectController extends Controller
|
||||
private const DESCRIPTION_MAX = 2000;
|
||||
private const MAX_PROJECTS_PER_OWNER = 100;
|
||||
|
||||
public function __construct(private readonly ProjectRepository $projects)
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectRepository $projects,
|
||||
private readonly CardStatusRepository $statuses,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +57,7 @@ final class ProjectController extends Controller
|
||||
}
|
||||
|
||||
$project = $this->projects->create($ownerId, $title, $description);
|
||||
$this->statuses->seedDefaults($project['id']);
|
||||
|
||||
return $this->json($response, ['project' => $this->present($project)], 201);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user