From dd7d217e8e16f1e796fab467eec2a79055fe70d3 Mon Sep 17 00:00:00 2001 From: Aneurin Barker Snook Date: Fri, 4 Sep 2026 20:31:10 +0100 Subject: [PATCH] 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: } 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 --- README.md | 17 +- src/Http/Controllers/CardStatusController.php | 117 +++++++- src/Repository/CardRepository.php | 23 ++ src/Repository/CardStatusRepository.php | 106 ++++++- src/bootstrap.php | 5 +- tests/CardStatusTest.php | 192 ++++++++++++- web/README.md | 34 ++- web/src/components/ProjectManageMenu.vue | 129 +++++++++ web/src/lib/api.ts | 6 + web/src/router/index.ts | 6 + web/src/style.css | 110 ++++++++ web/src/views/ProjectConfigureView.vue | 259 ++++++++++++++++++ web/src/views/ProjectView.vue | 117 +------- 13 files changed, 995 insertions(+), 126 deletions(-) create mode 100644 web/src/components/ProjectManageMenu.vue create mode 100644 web/src/views/ProjectConfigureView.vue diff --git a/README.md b/README.md index 29d9861..006ee06 100644 --- a/README.md +++ b/README.md @@ -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 | | 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 | +| 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 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: "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 -a project card must always have one) a referenced status can't be deleted at -the database level either. +rows, managed from the project's **configuration** view (create, reorder, +delete). A project always keeps at least one status, since a project card must +have one; deleting the last one is rejected (`409`). | Method | Path | Purpose | |--------|------|---------| | `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 { @@ -400,6 +404,13 @@ the database level either. Requires `Authorization: Bearer `; a project that is missing or not owned by 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": }` in +the same project; those cards are moved there and the status deleted, in one +transaction. + ### Error shape Every error response looks like: diff --git a/src/Http/Controllers/CardStatusController.php b/src/Http/Controllers/CardStatusController.php index 5d14d32..dd96d18 100644 --- a/src/Http/Controllers/CardStatusController.php +++ b/src/Http/Controllers/CardStatusController.php @@ -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": }. 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 $args */ diff --git a/src/Repository/CardRepository.php b/src/Repository/CardRepository.php index 118762e..3b6414f 100644 --- a/src/Repository/CardRepository.php +++ b/src/Repository/CardRepository.php @@ -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, diff --git a/src/Repository/CardStatusRepository.php b/src/Repository/CardStatusRepository.php index b6fe311..647ab7a 100644 --- a/src/Repository/CardStatusRepository.php +++ b/src/Repository/CardStatusRepository.php @@ -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 $row * @return CardStatusRow diff --git a/src/bootstrap.php b/src/bootstrap.php index 382da26..2214c5c 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -73,7 +73,7 @@ $authController = new AuthController($users, $session, $verifier, $config->allow $emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session); $projectController = new ProjectController($projects, $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); $authMiddleware = new AuthMiddleware($jwt, $users); @@ -120,6 +120,9 @@ $app->group('/api', function (RouteCollectorProxy $group) use ( $projects->delete('/{projectId:[0-9]+}', [$projectController, 'destroy']); $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->post('/{projectId:[0-9]+}/cards', [$cardController, 'store']); diff --git a/tests/CardStatusTest.php b/tests/CardStatusTest.php index 451f49d..bba62c9 100644 --- a/tests/CardStatusTest.php +++ b/tests/CardStatusTest.php @@ -80,21 +80,203 @@ final class CardStatusTest extends ApiTestCase 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. - // A card with a project must have a status (the CHECK constraint), so - // the FK is ON DELETE RESTRICT rather than SET NULL. + // The DELETE /statuses/{id} endpoint reassigns cards away before + // deleting (see below); this is the lower-level guarantee it relies + // 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(); $projectId = $this->newProject($auth); $statusId = $this->statuses($projectId, $auth)[0]['id']; $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $auth); - $this->db()->exec('PRAGMA foreign_keys = ON'); $this->expectException(PDOException::class); $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 { $auth = $this->authHeader(); diff --git a/web/README.md b/web/README.md index f4310c1..0fbfd23 100644 --- a/web/README.md +++ b/web/README.md @@ -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/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/views/ DashboardView, ProjectView, LoginView, ProfileView, - VerifyEmailView +src/components/ProjectManageMenu.vue "Manage" dropdown + delete-project modal, shared by ProjectView and ProjectConfigureView +src/views/ DashboardView, ProjectView, ProjectConfigureView, LoginView, + ProfileView, VerifyEmailView ``` 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 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 -inline-editable (saved on blur via `PATCH /api/projects/:id`). Manage has a -**Delete project** action that opens a confirmation modal; confirming calls -`DELETE /api/projects/:id` and returns to the dashboard. +inline-editable (saved on blur via `PATCH /api/projects/:id`). Manage +(`ProjectManageMenu.vue`) has a **Configure** link (to the status-management +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 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 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 There is no password and no separate sign-up — `LoginView` is an email field diff --git a/web/src/components/ProjectManageMenu.vue b/web/src/components/ProjectManageMenu.vue new file mode 100644 index 0000000..1511398 --- /dev/null +++ b/web/src/components/ProjectManageMenu.vue @@ -0,0 +1,129 @@ + + + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 165630b..d4d4e96 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -30,6 +30,12 @@ export class ApiError extends Error { const value = (this.details as Record).retry_after 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).card_count + return typeof value === 'number' ? value : undefined + } } interface RequestOptions { diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 1246880..fd4531a 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -17,6 +17,12 @@ const router = createRouter({ component: () => import('../views/ProjectView.vue'), 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', name: 'profile', diff --git a/web/src/style.css b/web/src/style.css index e9af754..882418e 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -508,6 +508,13 @@ h1 { background: var(--bg); } +.project-head__back { + display: inline-flex; + align-items: center; + flex: none; + text-decoration: none; +} + .menu { position: relative; flex: none; @@ -553,6 +560,7 @@ h1 { display: block; width: 100%; text-align: left; + text-decoration: none; border: none; background: none; color: var(--text); @@ -604,6 +612,108 @@ h1 { 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 { diff --git a/web/src/views/ProjectConfigureView.vue b/web/src/views/ProjectConfigureView.vue new file mode 100644 index 0000000..29f0f26 --- /dev/null +++ b/web/src/views/ProjectConfigureView.vue @@ -0,0 +1,259 @@ + + + diff --git a/web/src/views/ProjectView.vue b/web/src/views/ProjectView.vue index 57bc229..3af78c9 100644 --- a/web/src/views/ProjectView.vue +++ b/web/src/views/ProjectView.vue @@ -1,21 +1,19 @@