Project configuration view: manage a project's statuses
New /projects/:id/configure view, linked from a new 'Configure' item on
the project view's Manage menu.
Backend:
- CardStatusRepository/CardStatusController gain full CRUD: create
(appended at the end), reorder (dense positions, like card
ordering), and delete.
- Deleting a status with cards attached is rejected with 409 and
error.details.card_count, rather than hitting the existing FK
RESTRICT constraint -- retrying with { reassign_to: <status id> }
moves those cards to that status first (CardRepository::
reassignStatus, appended after the destination's existing cards)
and deletes in one transaction (CardStatusRepository::transaction,
shared PDO connection across repositories).
- The last status in a project can't be deleted, since a project card
is required to have one.
- Routes: POST/DELETE .../statuses(/:id), PUT .../statuses/order.
- 14 new CardStatusTest cases covering all of the above.
Frontend:
- ProjectConfigureView.vue: header (title, back-to-project link, the
shared Manage menu) + a vuedraggable status list (reorder persists
the whole new order) with a delete button per row and an add-status
form. A row's plain delete either succeeds immediately or, on 409,
opens a modal to choose a different status before retrying the
delete with reassign_to.
- Extracted ProjectManageMenu.vue (the Manage dropdown + delete-project
modal) out of ProjectView so both views share it; it now also has a
Configure link (hidden on the configure page itself).
- ApiError gains a cardCount getter (details.card_count), mirroring
the existing retryAfter getter.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,20 +5,27 @@ declare(strict_types=1);
|
||||
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;
|
||||
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.
|
||||
* A project's card statuses: listing, and managing them from the
|
||||
* configuration view (create, reorder, delete). Every project keeps at least
|
||||
* one status, since a project card is required to have one (see the CHECK
|
||||
* constraint on `cards`); deleting the last one is rejected.
|
||||
*/
|
||||
final class CardStatusController extends Controller
|
||||
{
|
||||
private const NAME_MAX = 100;
|
||||
|
||||
public function __construct(
|
||||
private readonly ProjectRepository $projects,
|
||||
private readonly CardStatusRepository $statuses,
|
||||
private readonly CardRepository $cards,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -34,6 +41,112 @@ final class CardStatusController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/projects/{projectId}/statuses
|
||||
*/
|
||||
public function store(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$projectId = $this->requireOwnedProjectId($request, $args);
|
||||
|
||||
$validator = new Validator($this->body($request));
|
||||
$name = $validator->requiredString('name', self::NAME_MAX);
|
||||
$validator->assert();
|
||||
|
||||
$status = $this->statuses->create($projectId, $name);
|
||||
|
||||
return $this->json($response, ['status' => $this->present($status)], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/projects/{projectId}/statuses/order
|
||||
*
|
||||
* Body: { "status_ids": [3, 1, 2] } -- every status in the project,
|
||||
* exactly once, in the desired order.
|
||||
*/
|
||||
public function reorder(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$projectId = $this->requireOwnedProjectId($request, $args);
|
||||
$body = $this->body($request);
|
||||
|
||||
$order = $body['status_ids'] ?? null;
|
||||
if (!is_array($order) || array_filter($order, static fn ($id) => !is_int($id)) !== []) {
|
||||
throw new ApiException('status_ids must be an array of status IDs.', 422);
|
||||
}
|
||||
/** @var int[] $order */
|
||||
if (count($order) !== count(array_unique($order))) {
|
||||
throw new ApiException('status_ids must not contain duplicates.', 422);
|
||||
}
|
||||
|
||||
$existingIds = array_column($this->statuses->allForProject($projectId), 'id');
|
||||
if (array_diff($order, $existingIds) !== [] || array_diff($existingIds, $order) !== []) {
|
||||
throw new ApiException('status_ids must include every status in this project, exactly once.', 422);
|
||||
}
|
||||
|
||||
$statuses = $this->statuses->reorder($projectId, $order);
|
||||
|
||||
return $this->json($response, ['statuses' => array_map($this->present(...), $statuses)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/projects/{projectId}/statuses/{statusId}
|
||||
*
|
||||
* Body (optional): { "reassign_to": <status id> }. If cards still use
|
||||
* this status, the request fails with 409 (details.card_count set)
|
||||
* unless reassign_to names a different status in the same project --
|
||||
* those cards are moved there first, then the status is deleted, in one
|
||||
* transaction.
|
||||
*/
|
||||
public function destroy(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$projectId = $this->requireOwnedProjectId($request, $args);
|
||||
$ownerId = $this->user($request)['id'];
|
||||
$statusId = (int) $args['statusId'];
|
||||
|
||||
$status = $this->statuses->findInProject($statusId, $projectId);
|
||||
if ($status === null) {
|
||||
throw new ApiException('Status not found.', 404);
|
||||
}
|
||||
if ($this->statuses->countForProject($projectId) <= 1) {
|
||||
throw new ApiException('A project must have at least one status.', 409);
|
||||
}
|
||||
|
||||
$cardCount = count($this->cards->idsInColumn($ownerId, $projectId, $statusId));
|
||||
$reassignTo = null;
|
||||
|
||||
if ($cardCount > 0) {
|
||||
$body = $this->body($request);
|
||||
$reassignTo = $body['reassign_to'] ?? null;
|
||||
|
||||
if ($reassignTo === null) {
|
||||
throw new ApiException(
|
||||
sprintf(
|
||||
'This status still has %d card%s. Choose another status to move them to.',
|
||||
$cardCount,
|
||||
$cardCount === 1 ? '' : 's',
|
||||
),
|
||||
409,
|
||||
['card_count' => $cardCount],
|
||||
);
|
||||
}
|
||||
if (!is_int($reassignTo) || $reassignTo === $statusId) {
|
||||
throw new ApiException('reassign_to must be a different status in this project.', 422);
|
||||
}
|
||||
if ($this->statuses->findInProject($reassignTo, $projectId) === null) {
|
||||
throw new ApiException('reassign_to must be a status in this project.', 422);
|
||||
}
|
||||
}
|
||||
|
||||
$this->statuses->transaction(function () use ($ownerId, $projectId, $statusId, $reassignTo) {
|
||||
if ($reassignTo !== null) {
|
||||
$this->cards->reassignStatus($ownerId, $projectId, $statusId, $reassignTo);
|
||||
}
|
||||
$this->statuses->delete($statusId);
|
||||
$this->statuses->repack($projectId);
|
||||
});
|
||||
|
||||
return $response->withStatus(204);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $args
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user