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:
2026-09-04 20:31:10 +01:00
co-authored by Claude Sonnet 5
parent 21e148aa4d
commit dd7d217e8e
13 changed files with 995 additions and 126 deletions
+14 -3
View File
@@ -23,6 +23,7 @@ Each user owns **projects**, and each project holds ordered **cards**.
| 13 | Global inbox — cards can have no project; moved into the sidebar, drag in/out of any project's kanban columns | ✅ done | | 13 | Global inbox — cards can have no project; moved into the sidebar, drag in/out of any project's kanban columns | ✅ done |
| 14 | New-project form moved to the dashboard; sidebar project list is now a switcher dropdown; Kanban is a project's default tab | ✅ done | | 14 | New-project form moved to the dashboard; sidebar project list is now a switcher dropdown; Kanban is a project's default tab | ✅ done |
| 15 | Passkeys (WebAuthn) — register from the profile page, sign in with one instead of a magic link; a dismissible notice nudges users with none | ✅ done | | 15 | Passkeys (WebAuthn) — register from the profile page, sign in with one instead of a magic link; a dismissible notice nudges users with none | ✅ done |
| 16 | Project configuration view — manage a project's statuses: add, drag to reorder, delete (reassigning any cards on it first) | ✅ done |
There is no password. Signing in is entering an email address and opening the There is no password. Signing in is entering an email address and opening the
magic link sent to it — the same step creates the account the first time. See magic link sent to it — the same step creates the account the first time. See
@@ -379,13 +380,16 @@ returns `{ "cards": [ … ] }`.
Every project has an ordered set of card statuses, created with the project: Every project has an ordered set of card statuses, created with the project:
"To do", "Doing", "Done". They are project-specific — each project owns its own "To do", "Doing", "Done". They are project-specific — each project owns its own
rows. There is no create/update/delete for the statuses themselves yet, and (as rows, managed from the project's **configuration** view (create, reorder,
a project card must always have one) a referenced status can't be deleted at delete). A project always keeps at least one status, since a project card must
the database level either. have one; deleting the last one is rejected (`409`).
| Method | Path | Purpose | | Method | Path | Purpose |
|--------|------|---------| |--------|------|---------|
| `GET` | `/api/projects/{id}/statuses` | the project's statuses, ordered by `position` | | `GET` | `/api/projects/{id}/statuses` | the project's statuses, ordered by `position` |
| `POST` | `/api/projects/{id}/statuses` | add one at the end — `{ "name": "Blocked" }` |
| `PUT` | `/api/projects/{id}/statuses/order` | reorder — `{ "status_ids": [3, 1, 2] }`, every status once |
| `DELETE` | `/api/projects/{id}/statuses/{statusId}` | delete (see below) |
```json ```json
{ {
@@ -400,6 +404,13 @@ the database level either.
Requires `Authorization: Bearer <jwt>`; a project that is missing or not owned by Requires `Authorization: Bearer <jwt>`; a project that is missing or not owned by
the caller responds `404`. the caller responds `404`.
**Deleting a status that still has cards** fails with `409` and
`error.details.card_count` set, rather than silently orphaning them (a
referenced status can't be deleted at the database level either — the FK is
`ON DELETE RESTRICT`). Retry with `{ "reassign_to": <another status id> }` in
the same project; those cards are moved there and the status deleted, in one
transaction.
### Error shape ### Error shape
Every error response looks like: Every error response looks like:
+115 -2
View File
@@ -5,20 +5,27 @@ declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Exception\ApiException; use App\Exception\ApiException;
use App\Repository\CardRepository;
use App\Repository\CardStatusRepository; use App\Repository\CardStatusRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Support\Validator;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
/** /**
* Read-only listing of a project's card statuses. Statuses are seeded when the * A project's card statuses: listing, and managing them from the
* project is created; there is no create/update/delete yet. * 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 final class CardStatusController extends Controller
{ {
private const NAME_MAX = 100;
public function __construct( public function __construct(
private readonly ProjectRepository $projects, private readonly ProjectRepository $projects,
private readonly CardStatusRepository $statuses, 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 * @param array<string, string> $args
*/ */
+23
View File
@@ -214,6 +214,29 @@ final class CardRepository
return $this->columnCards($ownerId, $projectId, $statusId); 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( private function insert(
int $ownerId, int $ownerId,
?int $projectId, ?int $projectId,
+104 -2
View File
@@ -8,8 +8,9 @@ use PDO;
/** /**
* Data access for the `card_statuses` table. Statuses belong to a single * 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 * project; every new project is seeded with a default set, and after that
* creation and that is the only way rows appear. * they're managed from the project's configuration view (create, reorder,
* delete).
* *
* @phpstan-type CardStatusRow array{ * @phpstan-type CardStatusRow array{
* id: int, project_id: int, name: string, position: int, * id: int, project_id: int, name: string, position: int,
@@ -51,6 +52,14 @@ final class CardStatusRepository
return $row === false ? null : $this->cast($row); 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. */ /** Insert the default status set for a freshly created project. */
public function seedDefaults(int $projectId): void 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 * @param array<string, mixed> $row
* @return CardStatusRow * @return CardStatusRow
+4 -1
View File
@@ -73,7 +73,7 @@ $authController = new AuthController($users, $session, $verifier, $config->allow
$emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session); $emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session);
$projectController = new ProjectController($projects, $cardStatuses); $projectController = new ProjectController($projects, $cardStatuses);
$cardController = new CardController($projects, $cards, $cardStatuses); $cardController = new CardController($projects, $cards, $cardStatuses);
$cardStatusController = new CardStatusController($projects, $cardStatuses); $cardStatusController = new CardStatusController($projects, $cardStatuses, $cards);
$passkeyController = new PasskeyController($webAuthn, $passkeys, $webauthnChallenges, $users, $session); $passkeyController = new PasskeyController($webAuthn, $passkeys, $webauthnChallenges, $users, $session);
$authMiddleware = new AuthMiddleware($jwt, $users); $authMiddleware = new AuthMiddleware($jwt, $users);
@@ -120,6 +120,9 @@ $app->group('/api', function (RouteCollectorProxy $group) use (
$projects->delete('/{projectId:[0-9]+}', [$projectController, 'destroy']); $projects->delete('/{projectId:[0-9]+}', [$projectController, 'destroy']);
$projects->get('/{projectId:[0-9]+}/statuses', [$cardStatusController, 'index']); $projects->get('/{projectId:[0-9]+}/statuses', [$cardStatusController, 'index']);
$projects->post('/{projectId:[0-9]+}/statuses', [$cardStatusController, 'store']);
$projects->put('/{projectId:[0-9]+}/statuses/order', [$cardStatusController, 'reorder']);
$projects->delete('/{projectId:[0-9]+}/statuses/{statusId:[0-9]+}', [$cardStatusController, 'destroy']);
$projects->get('/{projectId:[0-9]+}/cards', [$cardController, 'index']); $projects->get('/{projectId:[0-9]+}/cards', [$cardController, 'index']);
$projects->post('/{projectId:[0-9]+}/cards', [$cardController, 'store']); $projects->post('/{projectId:[0-9]+}/cards', [$cardController, 'store']);
+187 -5
View File
@@ -80,21 +80,203 @@ final class CardStatusTest extends ApiTestCase
self::assertSame($projectId, $card['project_id']); self::assertSame($projectId, $card['project_id']);
} }
public function test_a_referenced_status_cannot_be_deleted(): void public function test_a_referenced_status_cannot_be_deleted_at_the_database_level(): void
{ {
// There's no delete-status endpoint; this exercises the FK directly. // The DELETE /statuses/{id} endpoint reassigns cards away before
// A card with a project must have a status (the CHECK constraint), so // deleting (see below); this is the lower-level guarantee it relies
// the FK is ON DELETE RESTRICT rather than SET NULL. // on. A card with a project must have a status (the CHECK
// constraint), so the FK is ON DELETE RESTRICT rather than SET NULL.
$auth = $this->authHeader(); $auth = $this->authHeader();
$projectId = $this->newProject($auth); $projectId = $this->newProject($auth);
$statusId = $this->statuses($projectId, $auth)[0]['id']; $statusId = $this->statuses($projectId, $auth)[0]['id'];
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $auth); $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $auth);
$this->db()->exec('PRAGMA foreign_keys = ON');
$this->expectException(PDOException::class); $this->expectException(PDOException::class);
$this->db()->prepare('DELETE FROM card_statuses WHERE id = ?')->execute([$statusId]); $this->db()->prepare('DELETE FROM card_statuses WHERE id = ?')->execute([$statusId]);
} }
public function test_a_status_can_be_added(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$response = $this->request('POST', "/api/projects/{$projectId}/statuses", ['name' => 'Blocked'], $auth);
self::assertSame(201, $response->getStatusCode());
$status = $this->decode($response)['status'];
self::assertSame('Blocked', $status['name']);
self::assertSame(3, $status['position']);
self::assertSame(['To do', 'Doing', 'Done', 'Blocked'], array_column($this->statuses($projectId, $auth), 'name'));
}
public function test_adding_a_status_requires_a_name(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$response = $this->request('POST', "/api/projects/{$projectId}/statuses", ['name' => ''], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_a_status_cannot_be_added_to_another_owners_project(): void
{
$owner = $this->authHeader('owner@example.com');
$other = $this->authHeader('other@example.com');
$projectId = $this->newProject($owner, 'Private');
$response = $this->request('POST', "/api/projects/{$projectId}/statuses", ['name' => 'x'], $other);
self::assertSame(404, $response->getStatusCode());
}
public function test_statuses_can_be_reordered(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$ids = array_column($this->statuses($projectId, $auth), 'id');
$reversed = array_reverse($ids);
$response = $this->request(
'PUT',
"/api/projects/{$projectId}/statuses/order",
['status_ids' => $reversed],
$auth,
);
self::assertSame(200, $response->getStatusCode());
$statuses = $this->decode($response)['statuses'];
self::assertSame($reversed, array_column($statuses, 'id'));
self::assertSame([0, 1, 2], array_column($statuses, 'position'));
}
public function test_reordering_statuses_requires_every_status_exactly_once(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$ids = array_column($this->statuses($projectId, $auth), 'id');
$missing = $this->request(
'PUT',
"/api/projects/{$projectId}/statuses/order",
['status_ids' => array_slice($ids, 0, 2)],
$auth,
);
self::assertSame(422, $missing->getStatusCode());
$foreign = $this->request(
'PUT',
"/api/projects/{$projectId}/statuses/order",
['status_ids' => [...$ids, 999999]],
$auth,
);
self::assertSame(422, $foreign->getStatusCode());
}
public function test_an_empty_status_can_be_deleted_directly(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$statusId = $this->statuses($projectId, $auth)[0]['id'];
$response = $this->request('DELETE', "/api/projects/{$projectId}/statuses/{$statusId}", null, $auth);
self::assertSame(204, $response->getStatusCode());
self::assertSame(['Doing', 'Done'], array_column($this->statuses($projectId, $auth), 'name'));
self::assertSame([0, 1], array_column($this->statuses($projectId, $auth), 'position'));
}
public function test_deleting_a_status_with_cards_requires_reassignment(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$statusId = $this->statuses($projectId, $auth)[0]['id'];
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $auth);
$response = $this->request('DELETE', "/api/projects/{$projectId}/statuses/{$statusId}", null, $auth);
self::assertSame(409, $response->getStatusCode());
self::assertSame(1, $this->decode($response)['error']['details']['card_count']);
// Still there -- the delete was rejected, not partially applied.
self::assertSame(['To do', 'Doing', 'Done'], array_column($this->statuses($projectId, $auth), 'name'));
}
public function test_deleting_a_status_reassigns_its_cards(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
[$toDo, $doing] = $this->statuses($projectId, $auth);
$card = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $auth),
)['card'];
self::assertSame($toDo['id'], $card['status_id']);
$response = $this->request(
'DELETE',
"/api/projects/{$projectId}/statuses/{$toDo['id']}",
['reassign_to' => $doing['id']],
$auth,
);
self::assertSame(204, $response->getStatusCode());
self::assertSame(['Doing', 'Done'], array_column($this->statuses($projectId, $auth), 'name'));
$moved = $this->decode($this->request('GET', "/api/cards/{$card['id']}", null, $auth))['card'];
self::assertSame($doing['id'], $moved['status_id']);
}
public function test_reassign_to_must_be_a_different_status_in_the_same_project(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$other = $this->newProject($auth, 'Other');
$toDo = $this->statuses($projectId, $auth)[0];
$otherStatus = $this->statuses($other, $auth)[0];
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $auth);
$sameStatus = $this->request(
'DELETE',
"/api/projects/{$projectId}/statuses/{$toDo['id']}",
['reassign_to' => $toDo['id']],
$auth,
);
self::assertSame(422, $sameStatus->getStatusCode());
$foreignStatus = $this->request(
'DELETE',
"/api/projects/{$projectId}/statuses/{$toDo['id']}",
['reassign_to' => $otherStatus['id']],
$auth,
);
self::assertSame(422, $foreignStatus->getStatusCode());
}
public function test_the_last_status_in_a_project_cannot_be_deleted(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$ids = array_column($this->statuses($projectId, $auth), 'id');
$this->request('DELETE', "/api/projects/{$projectId}/statuses/{$ids[0]}", null, $auth);
$this->request('DELETE', "/api/projects/{$projectId}/statuses/{$ids[1]}", null, $auth);
$response = $this->request('DELETE', "/api/projects/{$projectId}/statuses/{$ids[2]}", null, $auth);
self::assertSame(409, $response->getStatusCode());
self::assertSame(1, count($this->statuses($projectId, $auth)));
}
public function test_a_status_cannot_be_deleted_from_another_owners_project(): void
{
$owner = $this->authHeader('owner@example.com');
$other = $this->authHeader('other@example.com');
$projectId = $this->newProject($owner, 'Private');
$statusId = $this->statuses($projectId, $owner)[0]['id'];
$response = $this->request('DELETE', "/api/projects/{$projectId}/statuses/{$statusId}", null, $other);
self::assertSame(404, $response->getStatusCode());
}
public function test_deleting_a_project_cascades_to_its_statuses(): void public function test_deleting_a_project_cascades_to_its_statuses(): void
{ {
$auth = $this->authHeader(); $auth = $this->authHeader();
+29 -5
View File
@@ -42,8 +42,9 @@ src/components/AppSidebar.vue left nav: Dashboard link, project dropdown, Inbox
src/components/CardRow.vue editable text + status chip + delete, one card src/components/CardRow.vue editable text + status chip + delete, one card
src/components/KanbanCard.vue small draggable card for the board columns and the inbox src/components/KanbanCard.vue small draggable card for the board columns and the inbox
src/components/PasskeyNotice.vue dismissible "add a passkey" banner across the top of the page src/components/PasskeyNotice.vue dismissible "add a passkey" banner across the top of the page
src/views/ DashboardView, ProjectView, LoginView, ProfileView, src/components/ProjectManageMenu.vue "Manage" dropdown + delete-project modal, shared by ProjectView and ProjectConfigureView
VerifyEmailView src/views/ DashboardView, ProjectView, ProjectConfigureView, LoginView,
ProfileView, VerifyEmailView
``` ```
Signed-in "app" routes (`meta.requiresAuth`) render inside a persistent shell: Signed-in "app" routes (`meta.requiresAuth`) render inside a persistent shell:
@@ -82,9 +83,10 @@ under the list adds a card straight to the inbox.
`/projects/:id` shows one project. It renders on a **full-width** layout (the `/projects/:id` shows one project. It renders on a **full-width** layout (the
route sets `meta.wide`, which widens `.app__main` in `App.vue`), so the header route sets `meta.wide`, which widens `.app__main` in `App.vue`), so the header
spans the full width and the **Manage** menu sits top right. The title is spans the full width and the **Manage** menu sits top right. The title is
inline-editable (saved on blur via `PATCH /api/projects/:id`). Manage has a inline-editable (saved on blur via `PATCH /api/projects/:id`). Manage
**Delete project** action that opens a confirmation modal; confirming calls (`ProjectManageMenu.vue`) has a **Configure** link (to the status-management
`DELETE /api/projects/:id` and returns to the dashboard. view below) and a **Delete project** action that opens a confirmation modal;
confirming calls `DELETE /api/projects/:id` and returns to the dashboard.
Below the header are two tabs (local `activeTab` state, `v-show` so both stay Below the header are two tabs (local `activeTab` state, `v-show` so both stay
mounted). The tab order is fixed — **All tasks** first, **Kanban** second — but mounted). The tab order is fixed — **All tasks** first, **Kanban** second — but
@@ -112,6 +114,28 @@ card and re-packs whatever column it left; afterwards the view always reloads
both `inbox` and this project's `cards`, since either could have been the other both `inbox` and this project's `cards`, since either could have been the other
side of the move. side of the move.
## Project configuration
`/projects/:id/configure` (`ProjectConfigureView.vue`) manages a project's
statuses. The header mirrors the project view's — title, `ProjectManageMenu`
top right — plus a "← Back to project" link (Manage's own Configure link is
hidden here, since it would just point at the current page).
The status list is a `vuedraggable` list (its own list, no shared drag group
with the kanban board) bound directly to a local `statuses` ref; dragging
mutates it in place, and `@change` persists the whole new order via
`PUT /api/projects/:id/statuses/order`, reverting to the server's copy on
failure. A small form below it adds a status
(`POST /api/projects/:id/statuses`) at the end of the list.
Each row has a delete button. A status with no cards deletes immediately; one
still holding cards gets `409` back from `DELETE .../statuses/:statusId` with
`error.details.card_count` (surfaced as `ApiError#cardCount`) -- that opens a
modal asking which other status to move its cards to, then resubmits the same
delete with `{ reassign_to }`, which reassigns and deletes in one request. The
last remaining status can't be deleted (a project card always needs one); its
row's delete button is disabled once `statuses.length <= 1`.
## Auth flow ## Auth flow
There is no password and no separate sign-up — `LoginView` is an email field There is no password and no separate sign-up — `LoginView` is an email field
+129
View File
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ApiError, apiRequest } from '../lib/api'
import { useCardsStore } from '../stores/cards'
import { useProjectsStore } from '../stores/projects'
// The "Manage" dropdown + its delete-project confirmation modal, shared by
// the project view and its configuration view -- each links to the other,
// and either can delete the project.
const props = defineProps<{
projectId: number
projectTitle: string
cardCount: number
}>()
const route = useRoute()
const router = useRouter()
const projects = useProjectsStore()
const cards = useCardsStore()
const menuOpen = ref(false)
const confirmingDelete = ref(false)
const deleting = ref(false)
const deleteError = ref<string | null>(null)
const cancelButton = ref<HTMLButtonElement>()
function askDelete() {
menuOpen.value = false
deleteError.value = null
confirmingDelete.value = true
}
async function confirmDelete() {
deleting.value = true
deleteError.value = null
try {
await apiRequest(`/projects/${props.projectId}`, { method: 'DELETE', auth: true })
projects.reset()
cards.reset()
await router.push('/')
} catch (e) {
deleteError.value = e instanceof ApiError ? e.message : 'Could not delete the project.'
deleting.value = false
}
}
watch(confirmingDelete, async (open) => {
if (open) {
await nextTick()
cancelButton.value?.focus()
}
})
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape') return
if (confirmingDelete.value) confirmingDelete.value = false
else if (menuOpen.value) menuOpen.value = false
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<div class="menu">
<button
type="button"
class="menu__toggle"
aria-haspopup="true"
:aria-expanded="menuOpen"
@click="menuOpen = !menuOpen"
>
Manage &#9662;
</button>
<template v-if="menuOpen">
<div class="menu__backdrop" @click="menuOpen = false" />
<ul class="menu__list" role="menu">
<li v-if="route.name !== 'project-configure'" role="none">
<RouterLink
:to="{ name: 'project-configure', params: { id: projectId } }"
role="menuitem"
class="menu__item"
@click="menuOpen = false"
>
Configure
</RouterLink>
</li>
<li role="none">
<button type="button" role="menuitem" class="menu__item menu__item--danger" @click="askDelete">
Delete project
</button>
</li>
</ul>
</template>
</div>
<div
v-if="confirmingDelete"
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-delete-title"
>
<div class="modal__backdrop" @click="confirmingDelete = false" />
<div class="modal__dialog">
<h2 id="confirm-delete-title">Delete this project?</h2>
<p class="muted">
&ldquo;{{ projectTitle }}&rdquo; and its {{ cardCount }}
card{{ cardCount === 1 ? '' : 's' }} will be permanently deleted.
</p>
<p v-if="deleteError" class="form-error">{{ deleteError }}</p>
<div class="modal__actions">
<button
ref="cancelButton"
type="button"
class="btn-secondary"
:disabled="deleting"
@click="confirmingDelete = false"
>
Cancel
</button>
<button type="button" class="btn-danger" :disabled="deleting" @click="confirmDelete">
{{ deleting ? 'Deleting…' : 'Delete project' }}
</button>
</div>
</div>
</div>
</template>
+6
View File
@@ -30,6 +30,12 @@ export class ApiError extends Error {
const value = (this.details as Record<string, unknown>).retry_after const value = (this.details as Record<string, unknown>).retry_after
return typeof value === 'number' ? value : undefined return typeof value === 'number' ? value : undefined
} }
/** How many cards are still using a status, when deleting one 409s pending reassignment. */
get cardCount(): number | undefined {
const value = (this.details as Record<string, unknown>).card_count
return typeof value === 'number' ? value : undefined
}
} }
interface RequestOptions { interface RequestOptions {
+6
View File
@@ -17,6 +17,12 @@ const router = createRouter({
component: () => import('../views/ProjectView.vue'), component: () => import('../views/ProjectView.vue'),
meta: { requiresAuth: true, wide: true }, meta: { requiresAuth: true, wide: true },
}, },
{
path: '/projects/:id(\\d+)/configure',
name: 'project-configure',
component: () => import('../views/ProjectConfigureView.vue'),
meta: { requiresAuth: true, wide: true },
},
{ {
path: '/profile', path: '/profile',
name: 'profile', name: 'profile',
+110
View File
@@ -508,6 +508,13 @@ h1 {
background: var(--bg); background: var(--bg);
} }
.project-head__back {
display: inline-flex;
align-items: center;
flex: none;
text-decoration: none;
}
.menu { .menu {
position: relative; position: relative;
flex: none; flex: none;
@@ -553,6 +560,7 @@ h1 {
display: block; display: block;
width: 100%; width: 100%;
text-align: left; text-align: left;
text-decoration: none;
border: none; border: none;
background: none; background: none;
color: var(--text); color: var(--text);
@@ -604,6 +612,108 @@ h1 {
max-width: 42rem; max-width: 42rem;
} }
/* --- project configuration: status list ------------------------------- */
.config-section {
max-width: 32rem;
margin-top: 1.5rem;
}
.config-section h2 {
margin: 0 0 0.25rem;
font-size: 1.1rem;
}
.status-list {
list-style: none;
margin: 1rem 0 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.status-row {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.6rem;
background: var(--surface);
cursor: grab;
}
.status-row--ghost {
opacity: 0.5;
}
.status-row__name {
flex: 1;
min-width: 0;
word-break: break-word;
}
.status-row__delete {
flex: none;
border: none;
background: none;
color: var(--muted);
cursor: pointer;
font-size: 1rem;
padding: 0.2rem 0.4rem;
border-radius: 6px;
}
.status-row__delete:hover:not(:disabled) {
color: var(--error);
}
.status-row__delete:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.status-list__new {
display: flex;
gap: 0.4rem;
margin-top: 0.75rem;
}
.status-list__new input {
flex: 1;
min-width: 0;
font: inherit;
font-size: 0.9rem;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.status-list__new button[type='submit'] {
flex: none;
padding: 0.4rem 0.7rem;
font-size: 0.9rem;
border-radius: 6px;
}
.modal__field {
display: grid;
gap: 0.3rem;
font-size: 0.9rem;
margin-top: 0.75rem;
}
.modal__field select {
padding: 0.5rem 0.6rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font: inherit;
}
/* --- kanban board ---------------------------------------------------- */ /* --- kanban board ---------------------------------------------------- */
.kanban { .kanban {
+259
View File
@@ -0,0 +1,259 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import draggable from 'vuedraggable'
import ProjectManageMenu from '../components/ProjectManageMenu.vue'
import { ApiError, apiRequest } from '../lib/api'
import type { CardStatus, Project } from '../types'
const route = useRoute()
const projectId = Number(route.params.id)
const project = ref<Project | null>(null)
const statuses = ref<CardStatus[]>([])
const loadError = ref<string | null>(null)
const statusActionError = ref<string | null>(null)
onMounted(() => void load())
async function load() {
loadError.value = null
try {
const [{ project: fetched }, { statuses: fetchedStatuses }] = await Promise.all([
apiRequest<{ project: Project }>(`/projects/${projectId}`, { auth: true }),
apiRequest<{ statuses: CardStatus[] }>(`/projects/${projectId}/statuses`, { auth: true }),
])
project.value = fetched
statuses.value = fetchedStatuses
} catch (e) {
if (e instanceof ApiError && e.status === 404) {
loadError.value = 'That project does not exist.'
} else {
loadError.value = e instanceof ApiError ? e.message : 'Could not load the project.'
}
}
}
// --- Reorder (drag-and-drop) ----------------------------------------------
// vuedraggable mutates `statuses` in place as the user drags; @change fires
// once the drop lands, and we persist the whole new order.
async function onStatusesReordered() {
statusActionError.value = null
try {
await apiRequest(`/projects/${projectId}/statuses/order`, {
method: 'PUT',
auth: true,
body: { status_ids: statuses.value.map((s) => s.id) },
})
} catch (e) {
statusActionError.value = e instanceof ApiError ? e.message : 'Could not save the new order.'
await load() // our optimistic local order may not match the server's
}
}
// --- Add ---------------------------------------------------------------
const newStatusName = ref('')
const addingStatus = ref(false)
const addStatusError = ref<ApiError | null>(null)
async function onAddStatus() {
addingStatus.value = true
addStatusError.value = null
try {
const { status } = await apiRequest<{ status: CardStatus }>(`/projects/${projectId}/statuses`, {
method: 'POST',
auth: true,
body: { name: newStatusName.value },
})
statuses.value.push(status)
newStatusName.value = ''
} catch (e) {
addStatusError.value = e instanceof ApiError ? e : new ApiError('Could not add the status.', 0)
} finally {
addingStatus.value = false
}
}
// --- Delete, with reassignment when cards are still using the status -----
const deletingStatusId = ref<number | null>(null)
// Set once a plain delete comes back 409 -- that status has cards, so we ask
// which other status to move them to before trying again.
const pendingDelete = ref<CardStatus | null>(null)
const pendingDeleteCardCount = ref(0)
const reassignTo = ref<number | ''>('')
const reassigning = ref(false)
const reassignCancelButton = ref<HTMLButtonElement>()
const reassignOptions = computed(() => statuses.value.filter((s) => s.id !== pendingDelete.value?.id))
async function onDeleteStatus(status: CardStatus) {
statusActionError.value = null
deletingStatusId.value = status.id
try {
await apiRequest(`/projects/${projectId}/statuses/${status.id}`, { method: 'DELETE', auth: true })
statuses.value = statuses.value.filter((s) => s.id !== status.id)
} catch (e) {
if (e instanceof ApiError && e.status === 409 && e.cardCount !== undefined) {
pendingDelete.value = status
pendingDeleteCardCount.value = e.cardCount
reassignTo.value = ''
} else {
statusActionError.value = e instanceof ApiError ? e.message : 'Could not delete the status.'
}
} finally {
deletingStatusId.value = null
}
}
function cancelReassign() {
pendingDelete.value = null
}
async function confirmReassignAndDelete() {
if (!pendingDelete.value || reassignTo.value === '') return
reassigning.value = true
statusActionError.value = null
try {
await apiRequest(`/projects/${projectId}/statuses/${pendingDelete.value.id}`, {
method: 'DELETE',
auth: true,
body: { reassign_to: reassignTo.value },
})
statuses.value = statuses.value.filter((s) => s.id !== pendingDelete.value?.id)
pendingDelete.value = null
} catch (e) {
statusActionError.value = e instanceof ApiError ? e.message : 'Could not reassign and delete the status.'
} finally {
reassigning.value = false
}
}
watch(pendingDelete, async (status) => {
if (status) {
await nextTick()
reassignCancelButton.value?.focus()
}
})
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && pendingDelete.value) cancelReassign()
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<section class="card project">
<p v-if="loadError" class="form-error">{{ loadError }}</p>
<template v-else-if="project">
<div class="project-head">
<RouterLink :to="{ name: 'project', params: { id: projectId } }" class="btn-secondary project-head__back">
&larr; Back to project
</RouterLink>
<h1 class="project-head__title">{{ project.title }}</h1>
<ProjectManageMenu :project-id="projectId" :project-title="project.title" :card-count="project.card_count" />
</div>
<section class="config-section">
<h2>Statuses</h2>
<p class="muted">
Drag to reorder. Deleting a status that still has cards asks where to move them first.
</p>
<p v-if="statusActionError" class="form-error">{{ statusActionError }}</p>
<draggable
:list="statuses"
item-key="id"
tag="ul"
class="status-list"
ghost-class="status-row--ghost"
:animation="150"
@change="onStatusesReordered"
>
<template #item="{ element: status }: { element: CardStatus }">
<li class="status-row">
<span class="status-row__name">{{ status.name }}</span>
<button
type="button"
class="status-row__delete"
:disabled="statuses.length <= 1 || deletingStatusId === status.id"
:title="statuses.length <= 1 ? 'A project must have at least one status.' : 'Delete status'"
:aria-label="`Delete status ${status.name}`"
@click="onDeleteStatus(status)"
>
&#10005;
</button>
</li>
</template>
</draggable>
<form class="status-list__new" @submit.prevent="onAddStatus">
<input
v-model="newStatusName"
type="text"
maxlength="100"
required
placeholder="Status name"
aria-label="Status name"
/>
<button type="submit" :disabled="addingStatus">{{ addingStatus ? 'Adding…' : 'Add' }}</button>
</form>
<p v-if="addStatusError" class="form-error">{{ addStatusError.message }}</p>
</section>
</template>
</section>
<div
v-if="pendingDelete"
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="reassign-title"
>
<div class="modal__backdrop" @click="cancelReassign" />
<div class="modal__dialog">
<h2 id="reassign-title">Move its cards first</h2>
<p class="muted">
&ldquo;{{ pendingDelete.name }}&rdquo; still has {{ pendingDeleteCardCount }}
card{{ pendingDeleteCardCount === 1 ? '' : 's' }}. Choose another status to move
{{ pendingDeleteCardCount === 1 ? 'it' : 'them' }} to before deleting it.
</p>
<label class="modal__field">
<span>Move cards to</span>
<select v-model="reassignTo">
<option value="" disabled>Choose a status</option>
<option v-for="s in reassignOptions" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</label>
<p v-if="statusActionError" class="form-error">{{ statusActionError }}</p>
<div class="modal__actions">
<button
ref="reassignCancelButton"
type="button"
class="btn-secondary"
:disabled="reassigning"
@click="cancelReassign"
>
Cancel
</button>
<button
type="button"
class="btn-danger"
:disabled="reassigning || reassignTo === ''"
@click="confirmReassignAndDelete"
>
{{ reassigning ? 'Moving…' : 'Move cards & delete' }}
</button>
</div>
</div>
</div>
</template>
+9 -108
View File
@@ -1,21 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute } from 'vue-router'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import CardRow from '../components/CardRow.vue' import CardRow from '../components/CardRow.vue'
import KanbanCard from '../components/KanbanCard.vue' import KanbanCard from '../components/KanbanCard.vue'
import ProjectManageMenu from '../components/ProjectManageMenu.vue'
import { ApiError, apiRequest } from '../lib/api' import { ApiError, apiRequest } from '../lib/api'
import { reorderColumn } from '../lib/cardOrder' import { reorderColumn } from '../lib/cardOrder'
import { useCardsStore } from '../stores/cards' import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox' import { useInboxStore } from '../stores/inbox'
import { useProjectsStore } from '../stores/projects'
import type { Card, CardStatus, Project } from '../types' import type { Card, CardStatus, Project } from '../types'
const route = useRoute() const route = useRoute()
const router = useRouter()
const cards = useCardsStore() const cards = useCardsStore()
const inbox = useInboxStore() const inbox = useInboxStore()
const projects = useProjectsStore()
const projectId = Number(route.params.id) const projectId = Number(route.params.id)
const project = ref<Project | null>(null) const project = ref<Project | null>(null)
@@ -32,12 +30,6 @@ const activeTab = ref<(typeof tabs)[number]['key']>('kanban')
const titleDraft = ref('') const titleDraft = ref('')
const menuOpen = ref(false)
const confirmingDelete = ref(false)
const deleting = ref(false)
const deleteError = ref<string | null>(null)
const cancelButton = ref<HTMLButtonElement>()
const newText = ref('') const newText = ref('')
const submitting = ref(false) const submitting = ref(false)
@@ -108,24 +100,7 @@ watch(project, (value) => {
if (value) titleDraft.value = value.title if (value) titleDraft.value = value.title
}) })
watch(confirmingDelete, async (open) => { onMounted(() => void load())
if (open) {
await nextTick()
cancelButton.value?.focus()
}
})
onMounted(() => {
void load()
window.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape') return
if (confirmingDelete.value) confirmingDelete.value = false
else if (menuOpen.value) menuOpen.value = false
}
async function load() { async function load() {
loadError.value = null loadError.value = null
@@ -172,26 +147,6 @@ function saveTitle() {
if (next !== project.value.title) void patchProject({ title: next }) if (next !== project.value.title) void patchProject({ title: next })
} }
function askDelete() {
menuOpen.value = false
deleteError.value = null
confirmingDelete.value = true
}
async function confirmDelete() {
deleting.value = true
deleteError.value = null
try {
await apiRequest(`/projects/${projectId}`, { method: 'DELETE', auth: true })
projects.reset()
cards.reset()
await router.push('/')
} catch (e) {
deleteError.value = e instanceof ApiError ? e.message : 'Could not delete the project.'
deleting.value = false
}
}
/** Run a store mutation, surfacing failures and resyncing from the server. */ /** Run a store mutation, surfacing failures and resyncing from the server. */
async function run(op: Promise<unknown>) { async function run(op: Promise<unknown>) {
actionError.value = null actionError.value = null
@@ -234,33 +189,11 @@ async function onCreate() {
/> />
</h1> </h1>
<div class="menu"> <ProjectManageMenu
<button :project-id="projectId"
type="button" :project-title="project.title"
class="menu__toggle" :card-count="cards.cards.length"
aria-haspopup="true" />
:aria-expanded="menuOpen"
@click="menuOpen = !menuOpen"
>
Manage &#9662;
</button>
<template v-if="menuOpen">
<div class="menu__backdrop" @click="menuOpen = false" />
<ul class="menu__list" role="menu">
<li role="none">
<button
type="button"
role="menuitem"
class="menu__item menu__item--danger"
@click="askDelete"
>
Delete project
</button>
</li>
</ul>
</template>
</div>
</div> </div>
<p v-if="actionError" class="form-error">{{ actionError }}</p> <p v-if="actionError" class="form-error">{{ actionError }}</p>
@@ -336,36 +269,4 @@ async function onCreate() {
</div> </div>
</template> </template>
</section> </section>
<div
v-if="confirmingDelete"
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-delete-title"
>
<div class="modal__backdrop" @click="confirmingDelete = false" />
<div class="modal__dialog">
<h2 id="confirm-delete-title">Delete this project?</h2>
<p class="muted">
&ldquo;{{ project?.title }}&rdquo; and its {{ cards.cards.length }}
card{{ cards.cards.length === 1 ? '' : 's' }} will be permanently deleted.
</p>
<p v-if="deleteError" class="form-error">{{ deleteError }}</p>
<div class="modal__actions">
<button
ref="cancelButton"
type="button"
class="btn-secondary"
:disabled="deleting"
@click="confirmingDelete = false"
>
Cancel
</button>
<button type="button" class="btn-danger" :disabled="deleting" @click="confirmDelete">
{{ deleting ? 'Deleting…' : 'Delete project' }}
</button>
</div>
</div>
</div>
</template> </template>