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:
@@ -214,6 +214,29 @@ final class CardRepository
|
||||
return $this->columnCards($ownerId, $projectId, $statusId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move every card in one project status to another status in the same
|
||||
* project -- used when a status is deleted out from under them. Appended
|
||||
* after the destination's existing cards, so both columns stay a dense
|
||||
* 0..n-1 with no repack needed.
|
||||
*/
|
||||
public function reassignStatus(int $ownerId, int $projectId, int $fromStatusId, int $toStatusId): void
|
||||
{
|
||||
$ids = $this->idsInColumn($ownerId, $projectId, $fromStatusId);
|
||||
if ($ids === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$next = $this->nextPositionInColumn($ownerId, $projectId, $toStatusId);
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE cards SET status_id = :status, position = :position, updated_at = ' . $this->nowExpr() . '
|
||||
WHERE id = :id AND owner_id = :owner'
|
||||
);
|
||||
foreach ($ids as $i => $id) {
|
||||
$stmt->execute(['status' => $toStatusId, 'position' => $next + $i, 'id' => $id, 'owner' => $ownerId]);
|
||||
}
|
||||
}
|
||||
|
||||
private function insert(
|
||||
int $ownerId,
|
||||
?int $projectId,
|
||||
|
||||
@@ -8,8 +8,9 @@ use PDO;
|
||||
|
||||
/**
|
||||
* Data access for the `card_statuses` table. Statuses belong to a single
|
||||
* project. There is no CRUD yet — projects are seeded with a fixed set on
|
||||
* creation and that is the only way rows appear.
|
||||
* project; every new project is seeded with a default set, and after that
|
||||
* they're managed from the project's configuration view (create, reorder,
|
||||
* delete).
|
||||
*
|
||||
* @phpstan-type CardStatusRow array{
|
||||
* id: int, project_id: int, name: string, position: int,
|
||||
@@ -51,6 +52,14 @@ final class CardStatusRepository
|
||||
return $row === false ? null : $this->cast($row);
|
||||
}
|
||||
|
||||
public function countForProject(int $projectId): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM card_statuses WHERE project_id = :project');
|
||||
$stmt->execute(['project' => $projectId]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/** Insert the default status set for a freshly created project. */
|
||||
public function seedDefaults(int $projectId): void
|
||||
{
|
||||
@@ -63,6 +72,99 @@ final class CardStatusRepository
|
||||
}
|
||||
}
|
||||
|
||||
/** Add a status at the end of the project's list. */
|
||||
public function create(int $projectId, string $name): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO card_statuses (project_id, name, position) VALUES (:project, :name, :position)'
|
||||
);
|
||||
$stmt->execute(['project' => $projectId, 'name' => $name, 'position' => $this->nextPosition($projectId)]);
|
||||
|
||||
/** @var CardStatusRow $status */
|
||||
$status = $this->find((int) $this->pdo->lastInsertId());
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->pdo->prepare('DELETE FROM card_statuses WHERE id = :id')->execute(['id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder a project's statuses to match $orderedIds (0..n-1).
|
||||
*
|
||||
* @param int[] $orderedIds
|
||||
* @return CardStatusRow[]
|
||||
*/
|
||||
public function reorder(int $projectId, array $orderedIds): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE card_statuses SET position = :position, updated_at = ' . $this->nowExpr() . '
|
||||
WHERE id = :id AND project_id = :project'
|
||||
);
|
||||
foreach (array_values($orderedIds) as $position => $id) {
|
||||
$stmt->execute(['position' => $position, 'id' => $id, 'project' => $projectId]);
|
||||
}
|
||||
|
||||
return $this->allForProject($projectId);
|
||||
}
|
||||
|
||||
/** Rewrite a project's status positions to a dense 0..n-1, current order preserved. */
|
||||
public function repack(int $projectId): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare('UPDATE card_statuses SET position = :position WHERE id = :id');
|
||||
foreach (array_column($this->allForProject($projectId), 'id') as $position => $id) {
|
||||
$stmt->execute(['position' => $position, 'id' => $id]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run $fn in a transaction on the connection shared with the other
|
||||
* repositories (they're all built on the same PDO instance -- see
|
||||
* bootstrap.php) -- for operations, like deleting a status, that need to
|
||||
* write through more than one repository atomically.
|
||||
*/
|
||||
public function transaction(callable $fn): mixed
|
||||
{
|
||||
$this->pdo->beginTransaction();
|
||||
try {
|
||||
$result = $fn();
|
||||
$this->pdo->commit();
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
$this->pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function nextPosition(int $projectId): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT COALESCE(MAX(position), -1) + 1 FROM card_statuses WHERE project_id = :project');
|
||||
$stmt->execute(['project' => $projectId]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return CardStatusRow|null
|
||||
*/
|
||||
private function find(int $id): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM card_statuses WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return $row === false ? null : $this->cast($row);
|
||||
}
|
||||
|
||||
private function nowExpr(): string
|
||||
{
|
||||
return "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return CardStatusRow
|
||||
|
||||
Reference in New Issue
Block a user