Make the inbox global instead of per-project

A card either sits in its owner's inbox (project_id AND status_id both NULL)
or belongs to exactly one project with a status in it (both set) -- enforced
by a CHECK constraint, never one without the other. The inbox is global to a
user now, not per-project: cards can move from a project into the inbox and
back into any status column of any project.

Backend
- migrations/009: rebuilds `cards` (SQLite can't relax NOT NULL / add a CHECK
  in place) with a nullable project_id, a new owner_id (cards need direct
  ownership once they can have no project), and the CHECK constraint. Cards
  that had no status (the old per-project inbox) move to the new global inbox.
  status_id's FK is now ON DELETE RESTRICT, not SET NULL -- nulling it alone
  would violate the invariant, and there's no status-delete endpoint anyway.
- CardRepository: "column" is now (owner_id, project_id, status_id); every
  method that dealt with a project's columns is generalised to also cover the
  inbox and cross-project moves (orderColumn, idsInColumn, repack, ...).
- CardController/routes: single-card and ordering routes move to global,
  since a card may have no project to nest them under --
  GET/PATCH/DELETE /api/cards/{id}, PUT /api/cards/order (body now takes
  project_id + status_id, both null for the inbox). New GET/POST
  /api/inbox/cards. PATCH no longer accepts status_id -- moving a card, in or
  out of a project, is exclusively PUT /api/cards/order now. A card created
  directly in a project (POST /api/projects/{id}/cards) lands in its first
  status, since a project card can't have no status.
- Tests: ProjectTest/CardStatusTest updated for the new routes; CardOrderTest
  rewritten with full inbox/cross-project coverage. 57 tests pass.

Frontend
- New stores/inbox.ts (the global inbox) and lib/cardOrder.ts (the shared
  PUT /api/cards/order call, used by both the sidebar and a project's board).
- AppSidebar: an Inbox section under the project list -- a vuedraggable list
  in the same "kanban" drag group as every project's kanban columns, so a
  card drags straight from the sidebar into whichever project is open, or
  back out. (The empty-inbox state needed a real bugfix: it wasn't rendering
  a <draggable> at all, so there was nowhere to drop a card back into an
  empty inbox.) A drop reloads the inbox and, if a project is open, its cards.
- ProjectView's kanban board drops its synthetic Inbox column -- just the
  real statuses now.
- DashboardView simplified to a plain grid of project tiles (name + card
  count); its per-project "New" section is gone, since a project card can no
  longer have no status.
- stores/cards.ts: patch/remove move to the global /api/cards/{id} routes.

Verified end-to-end against the rebuilt container (existing per-project-inbox
cards correctly migrated to the global inbox, 0 invariant violations) and the
dev server via headless Chrome: sidebar inbox -> project A "To do" -> back to
inbox -> project B "Done", full journey confirmed via the API at each step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 15:22:33 +01:00
co-authored by Claude Sonnet 5
parent c82bdbbf0e
commit 4ee0f24078
19 changed files with 957 additions and 674 deletions
+40 -34
View File
@@ -18,8 +18,9 @@ Each user owns **projects**, and each project holds ordered **cards**.
| 8 | Passwordless login — magic-link by default, password login behind a toggle | ✅ done | | 8 | Passwordless login — magic-link by default, password login behind a toggle | ✅ done |
| 9 | Per-project card statuses ("To do" / "Doing" / "Done"); status chip, new cards start with none | ✅ done | | 9 | Per-project card statuses ("To do" / "Doing" / "Done"); status chip, new cards start with none | ✅ done |
| 10 | Project view — full-width, tabbed: alphabetical "All tasks" list + "Kanban" board, per-column drag ordering | ✅ done | | 10 | Project view — full-width, tabbed: alphabetical "All tasks" list + "Kanban" board, per-column drag ordering | ✅ done |
| 11 | Persistent left sidebar (Dashboard link + project list/new-project form); dashboard = grid of projects with their "New" inbox cards | ✅ done | | 11 | Persistent left sidebar (Dashboard link + project list/new-project form); dashboard = grid of project tiles | ✅ done |
| 12 | Passwordless-only auth — registration and password login removed; a magic link is the sole way in, and creates the account if needed | ✅ done | | 12 | Passwordless-only auth — registration and password login removed; a magic link is the sole way in, and creates the account if needed | ✅ done |
| 13 | Global inbox — cards can have no project; moved into the sidebar, drag in/out of any project's kanban columns | ✅ 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
@@ -258,41 +259,44 @@ Creating a project also seeds it with three **statuses** — "To do", "Doing",
### Cards ### Cards
Scoped to a project; the parent project's ownership is checked first A card either sits in its owner's **inbox** (`project_id` and `status_id` both
(`404` otherwise). `null`) or belongs to exactly one of their projects with a status in it (both
set) — enforced by a database CHECK constraint, never one without the other.
The inbox is global to the user, not per-project. Because a card may have no
project, single-card and ordering routes are addressed globally, by the card's
own id, rather than nested under a project:
| Method | Path | Purpose | | Method | Path | Purpose |
|--------|------|---------| |--------|------|---------|
| `GET` | `/api/projects/{id}/cards` | every card, grouped by column (inbox first) then `position` | | `GET` | `/api/projects/{id}/cards` | a project's cards, grouped by status then `position` |
| `POST` | `/api/projects/{id}/cards` | add a card | | `POST` | `/api/projects/{id}/cards` | add a card directly to the project (its first status) |
| `PUT` | `/api/projects/{id}/cards/order` | set the order/contents of one status column | | `GET` | `/api/inbox/cards` | the caller's inbox |
| `GET` | `/api/projects/{id}/cards/{cardId}` | one card | | `POST` | `/api/inbox/cards` | add a card to the inbox |
| `PATCH` | `/api/projects/{id}/cards/{cardId}` | update `text`, `complete`, and/or `status_id` | | `GET` \| `PATCH` \| `DELETE` | `/api/cards/{cardId}` | one card, owner-scoped (`404` otherwise) |
| `DELETE` | `/api/projects/{id}/cards/{cardId}` | delete the card (`204`) | | `PUT` | `/api/cards/order` | set the order/contents of one column |
Create body: `text` (required, 11000 chars), `complete` (optional bool, Create body (either creation route): `text` (required, 11000 chars),
default `false`). `PATCH` needs at least one field. `complete` (optional bool, default `false`). `PATCH` accepts `text` and/or
`complete` only — moving a card is done via the order route below, not PATCH.
**Ordering.** `position` is a dense `0..n-1` rank *within a column* — the cards **Ordering.** `position` is a dense `0..n-1` rank *within a column* — the cards
that share a `(project_id, status_id)`. The inbox (`status_id IS NULL`) is its that share an `(owner, project, status)`. The inbox is its own column, per
own column. New cards go to the end of the inbox. There is no project-wide order. owner. `PUT /api/cards/order` sets one column's contents and order:
A new card has **no** status (`status_id: null`) — it sits in the project ```json
"inbox" until the user gives it one. Two ways to move it: { "project_id": 5, "status_id": 12, "card_ids": [3, 1, 2] }
```
- `PATCH …/cards/{cardId}` with `status_id` (a status id in this project, or `project_id`/`status_id` are both `null` for the inbox, or both set to a
`null` for the inbox) — appends the card to the end of the destination column project owned by the caller and one of its statuses (`404`/`422` otherwise).
and re-packs the one it left. `422` for an unknown or foreign status. `card_ids` must be distinct cards owned by the caller and must include every
- `PUT …/cards/order` with `{ "status_id": <id|null>, "card_ids": [3, 1, 2] }` card already in the target column (`422` otherwise); it rewrites positions to
makes those cards the exact contents of that column, in that order (positions `0..n-1`. Any card in the list that wasn't already in that column is
rewritten to `0..n-1`). Any card dragged in from another column is re-parented re-parented into it — moving it from another project's status, or the inbox,
and its old column re-packed, all in one transaction. `card_ids` must be or vice versa — and the column it left is re-packed, all in one transaction.
distinct cards of this project and must include every card already in the Returns `{ "cards": [ … ] }` for the new column. This is what dragging a card
target column (`422` otherwise). Returns `{ "cards": [ … ] }` for the whole in the kanban board (or the sidebar's inbox) calls on every drop; moving a
project. This is what the kanban board calls on every drop. card from one project to another is just two calls, via the inbox in between.
A status row that is deleted clears itself from its cards rather than deleting
them.
Card representation: Card representation:
@@ -304,23 +308,25 @@ Card representation:
"text": "Design homepage", "text": "Design homepage",
"complete": false, "complete": false,
"position": 0, "position": 0,
"status_id": null, "status_id": 2,
"status": null, "status": { "id": 2, "name": "Doing" },
"created_at": "2026-09-03T12:00:00Z", "created_at": "2026-09-03T12:00:00Z",
"updated_at": "2026-09-03T12:00:00Z" "updated_at": "2026-09-03T12:00:00Z"
} }
} }
``` ```
`status` is the embedded `{ id, name }` of the linked status, or `null` when the `project_id` and `status_id` are `null` together for an inbox card. `status` is
card has none. `GET …/cards` returns `{ "cards": [ … ] }`. the embedded `{ id, name }` of the linked status, or `null`. `GET …/cards`
returns `{ "cards": [ … ] }`.
### Statuses ### Statuses
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; a card rows. There is no create/update/delete for the statuses themselves yet, and (as
is moved between them (or to the inbox) via `PATCH …/cards/{cardId}`. a project card must always have one) a referenced status can't be deleted at
the database level either.
| Method | Path | Purpose | | Method | Path | Purpose |
|--------|------|---------| |--------|------|---------|
+58
View File
@@ -0,0 +1,58 @@
-- The inbox becomes global to a user rather than per-project: a card now
-- either belongs to nobody's project (project_id AND status_id both NULL --
-- sitting in that user's inbox) or to exactly one project with a status in it
-- (both set). Cards also gain a direct owner_id, since inbox cards have no
-- project to derive ownership from.
--
-- SQLite can't relax an existing NOT NULL / add a CHECK to a live column, so
-- the table is rebuilt.
CREATE TABLE cards_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
project_id INTEGER NULL REFERENCES projects (id) ON DELETE CASCADE,
-- Was ON DELETE SET NULL; now that a project card must have a status
-- (the CHECK below), silently nulling status_id on a deleted status would
-- leave project_id orphaned without it. There is no status-delete
-- endpoint yet, so this is only a safety net.
status_id INTEGER NULL REFERENCES card_statuses (id) ON DELETE RESTRICT,
text TEXT NOT NULL,
complete INTEGER NOT NULL DEFAULT 0 CHECK (complete IN (0, 1)),
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
CHECK ((project_id IS NULL) = (status_id IS NULL))
);
-- Existing cards with no status were sitting in their project's old
-- per-project inbox column; that concept is gone, so they move to the (now
-- global, per-owner) inbox -- project_id cleared alongside status_id.
INSERT INTO cards_new (id, owner_id, project_id, status_id, text, complete, position, created_at, updated_at)
SELECT c.id,
p.owner_id,
CASE WHEN c.status_id IS NULL THEN NULL ELSE c.project_id END,
c.status_id,
c.text, c.complete, c.position, c.created_at, c.updated_at
FROM cards c
JOIN projects p ON p.id = c.project_id;
DROP TABLE cards;
ALTER TABLE cards_new RENAME TO cards;
CREATE INDEX idx_cards_owner_project_status_position
ON cards (owner_id, project_id, status_id, position);
CREATE INDEX idx_cards_status ON cards (status_id);
-- Re-pack every column -- including each owner's inbox -- to a dense 0..n-1,
-- preserving relative order. Safe despite writing the table it reads: the CTE
-- is evaluated against the pre-update snapshot.
WITH ranked (id, rk) AS (
SELECT id,
row_number() OVER (
PARTITION BY owner_id, project_id, status_id
ORDER BY position, id
) - 1
FROM cards
)
UPDATE cards
SET position = (SELECT rk FROM ranked WHERE ranked.id = cards.id);
+123 -52
View File
@@ -13,8 +13,10 @@ use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
/** /**
* CRUD for the cards within one project. Every route first checks that the * CRUD for cards. A card either sits in the caller's inbox (no project) or
* parent project is owned by the authenticated user; otherwise it responds 404. * belongs to one of their projects with a status in it; single-card and
* ordering routes are addressed globally (by card id, or by an explicit
* project_id/status_id column) since a card need not have a project.
*/ */
final class CardController extends Controller final class CardController extends Controller
{ {
@@ -41,6 +43,9 @@ final class CardController extends Controller
/** /**
* POST /api/projects/{projectId}/cards * POST /api/projects/{projectId}/cards
*
* Creates the card directly in the project, in its first status (a
* project card always has one -- see the invariant on the `cards` table).
*/ */
public function store(Request $request, Response $response, array $args): Response public function store(Request $request, Response $response, array $args): Response
{ {
@@ -52,29 +57,62 @@ final class CardController extends Controller
$position = $validator->optionalInt('position', 0); $position = $validator->optionalInt('position', 0);
$validator->assert(); $validator->assert();
$card = $this->cards->create($projectId, $text, $complete, $position); $card = $this->cards->createInProject(
$this->user($request)['id'],
$projectId,
$this->firstStatusId($projectId),
$text,
$complete,
$position,
);
$this->projects->touch($projectId); $this->projects->touch($projectId);
return $this->json($response, ['card' => $this->present($card)], 201); return $this->json($response, ['card' => $this->present($card)], 201);
} }
/** /**
* GET /api/projects/{projectId}/cards/{cardId} * GET /api/inbox/cards
*/ */
public function show(Request $request, Response $response, array $args): Response public function inboxIndex(Request $request, Response $response): Response
{ {
$projectId = $this->requireOwnedProjectId($request, $args); $cards = $this->cards->allInInbox($this->user($request)['id']);
return $this->json($response, ['card' => $this->present($this->requireCard($projectId, $args))]); return $this->json($response, ['cards' => array_map($this->present(...), $cards)]);
} }
/** /**
* PATCH /api/projects/{projectId}/cards/{cardId} * POST /api/inbox/cards
*/
public function inboxStore(Request $request, Response $response): Response
{
$validator = new Validator($this->body($request));
$text = $validator->requiredString('text', self::TEXT_MAX);
$complete = $validator->optionalBool('complete') ?? false;
$position = $validator->optionalInt('position', 0);
$validator->assert();
$card = $this->cards->createInInbox($this->user($request)['id'], $text, $complete, $position);
return $this->json($response, ['card' => $this->present($card)], 201);
}
/**
* GET /api/cards/{cardId}
*/
public function show(Request $request, Response $response, array $args): Response
{
return $this->json($response, ['card' => $this->present($this->requireCard($request, $args))]);
}
/**
* PATCH /api/cards/{cardId}
*
* Text and completion only. Moving a card -- into/out of the inbox, or
* between projects -- goes through PUT /api/cards/order.
*/ */
public function update(Request $request, Response $response, array $args): Response public function update(Request $request, Response $response, array $args): Response
{ {
$projectId = $this->requireOwnedProjectId($request, $args); $card = $this->requireCard($request, $args);
$card = $this->requireCard($projectId, $args);
$validator = new Validator($this->body($request)); $validator = new Validator($this->body($request));
$fields = []; $fields = [];
@@ -84,63 +122,51 @@ final class CardController extends Controller
if ($validator->has('complete')) { if ($validator->has('complete')) {
$fields['complete'] = $validator->optionalBool('complete'); $fields['complete'] = $validator->optionalBool('complete');
} }
if ($validator->has('status_id')) {
$statusId = $validator->nullableInt('status_id', 1);
if ($statusId !== null && !$validator->failed()
&& $this->statuses->findInProject($statusId, $projectId) === null
) {
$validator->add('status_id', 'That status does not belong to this project.');
}
$fields['status_id'] = $statusId;
}
if ($fields === [] && !$validator->failed()) { if ($fields === [] && !$validator->failed()) {
$validator->add('text', 'Provide at least one of: text, complete, status_id.'); $validator->add('text', 'Provide at least one of: text, complete.');
} }
$validator->assert(); $validator->assert();
$updated = $this->cards->update($card['id'], $projectId, $fields); $updated = $this->cards->update($card['id'], $fields);
$this->projects->touch($projectId); if ($card['project_id'] !== null) {
$this->projects->touch($card['project_id']);
}
return $this->json($response, ['card' => $this->present($updated)]); return $this->json($response, ['card' => $this->present($updated)]);
} }
/** /**
* DELETE /api/projects/{projectId}/cards/{cardId} * DELETE /api/cards/{cardId}
*/ */
public function destroy(Request $request, Response $response, array $args): Response public function destroy(Request $request, Response $response, array $args): Response
{ {
$projectId = $this->requireOwnedProjectId($request, $args); $card = $this->requireCard($request, $args);
$this->cards->delete($this->requireCard($projectId, $args)['id']); $this->cards->delete($card['id']);
$this->projects->touch($projectId); if ($card['project_id'] !== null) {
$this->projects->touch($card['project_id']);
}
return $response->withStatus(204); return $response->withStatus(204);
} }
/** /**
* PUT /api/projects/{projectId}/cards/order * PUT /api/cards/order
* *
* Sets the contents and order of one status column. * Sets the contents and order of one column.
* *
* Body: { "status_id": 2 | null, "card_ids": [3, 1, 2] } — the cards that * Body: { "project_id": 5 | null, "status_id": 2 | null, "card_ids": [3, 1, 2] }
* should make up that column, in order. Positions are rewritten to 0..n-1; * -- both null for the inbox, or both set to a project owned by the
* any card moved in from another column is re-parented and its old column * caller and one of its statuses. `card_ids` are the cards that should
* re-packed. `status_id` is omitted or null for the inbox. * make up that column, in order; any card moved in from elsewhere
* (another project, the inbox) is re-parented and its old column
* re-packed.
*/ */
public function reorder(Request $request, Response $response, array $args): Response public function reorder(Request $request, Response $response): Response
{ {
$projectId = $this->requireOwnedProjectId($request, $args); $ownerId = $this->user($request)['id'];
$body = $this->body($request); $body = $this->body($request);
$statusId = null; [$projectId, $statusId] = $this->targetColumn($ownerId, $body);
if (array_key_exists('status_id', $body) && $body['status_id'] !== null) {
if (!is_int($body['status_id'])) {
throw new ApiException('status_id must be a status ID or null.', 422);
}
$statusId = $body['status_id'];
if ($this->statuses->findInProject($statusId, $projectId) === null) {
throw new ApiException('That status does not belong to this project.', 422);
}
}
$order = $body['card_ids'] ?? null; $order = $body['card_ids'] ?? null;
if (!is_array($order) || array_filter($order, static fn ($id) => !is_int($id)) !== []) { if (!is_array($order) || array_filter($order, static fn ($id) => !is_int($id)) !== []) {
@@ -150,19 +176,64 @@ final class CardController extends Controller
if (count($order) !== count(array_unique($order))) { if (count($order) !== count(array_unique($order))) {
throw new ApiException('card_ids must not contain duplicates.', 422); throw new ApiException('card_ids must not contain duplicates.', 422);
} }
if (array_diff($order, $this->cards->idsForProject($projectId)) !== []) { if (array_diff($order, $this->cards->idsOwnedBy($ownerId)) !== []) {
throw new ApiException('Every card_id must be a card in this project.', 422); throw new ApiException('Every card_id must be a card you own.', 422);
} }
if (array_diff($this->cards->idsInColumn($projectId, $statusId), $order) !== []) { if (array_diff($this->cards->idsInColumn($ownerId, $projectId, $statusId), $order) !== []) {
throw new ApiException('card_ids must include every card already in this column.', 422); throw new ApiException('card_ids must include every card already in this column.', 422);
} }
$cards = $this->cards->orderColumn($projectId, $statusId, $order); $cards = $this->cards->orderColumn($ownerId, $projectId, $statusId, $order);
if ($projectId !== null) {
$this->projects->touch($projectId); $this->projects->touch($projectId);
}
return $this->json($response, ['cards' => array_map($this->present(...), $cards)]); return $this->json($response, ['cards' => array_map($this->present(...), $cards)]);
} }
/**
* Validate and resolve the { project_id, status_id } target column from
* the request body.
*
* @param array<string, mixed> $body
* @return array{0: int|null, 1: int|null}
*/
private function targetColumn(int $ownerId, array $body): array
{
$projectId = $body['project_id'] ?? null;
$statusId = $body['status_id'] ?? null;
if ($projectId === null) {
if ($statusId !== null) {
throw new ApiException('status_id must be null when project_id is null.', 422);
}
return [null, null];
}
if (!is_int($projectId)) {
throw new ApiException('project_id must be an integer or null.', 422);
}
if ($this->projects->findOwnedBy($projectId, $ownerId) === null) {
throw new ApiException('Project not found.', 404);
}
if (!is_int($statusId)) {
throw new ApiException('status_id is required when project_id is set.', 422);
}
if ($this->statuses->findInProject($statusId, $projectId) === null) {
throw new ApiException('That status does not belong to this project.', 422);
}
return [$projectId, $statusId];
}
/** Every project is seeded with statuses on creation; this is never empty. */
private function firstStatusId(int $projectId): int
{
return $this->statuses->allForProject($projectId)[0]['id'];
}
/** /**
* @param array<string, string> $args * @param array<string, string> $args
*/ */
@@ -179,11 +250,11 @@ final class CardController extends Controller
/** /**
* @param array<string, string> $args * @param array<string, string> $args
* @return array{id: int, project_id: int, text: string, complete: bool, position: int, status_id: int|null, status: array{id: int, name: string}|null, created_at: string, updated_at: string} * @return array{id: int, owner_id: int, project_id: int|null, text: string, complete: bool, position: int, status_id: int|null, status: array{id: int, name: string}|null, created_at: string, updated_at: string}
*/ */
private function requireCard(int $projectId, array $args): array private function requireCard(Request $request, array $args): array
{ {
$card = $this->cards->findInProject((int) $args['cardId'], $projectId); $card = $this->cards->findOwnedBy((int) $args['cardId'], $this->user($request)['id']);
if ($card === null) { if ($card === null) {
throw new ApiException('Card not found.', 404); throw new ApiException('Card not found.', 404);
@@ -193,7 +264,7 @@ final class CardController extends Controller
} }
/** /**
* @param array{id: int, project_id: int, text: string, complete: bool, position: int, status_id: int|null, status: array{id: int, name: string}|null, created_at: string, updated_at: string} $card * @param array{id: int, owner_id: int, project_id: int|null, text: string, complete: bool, position: int, status_id: int|null, status: array{id: int, name: string}|null, created_at: string, updated_at: string} $card
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function present(array $card): array private function present(array $card): array
+163 -111
View File
@@ -9,12 +9,14 @@ use PDO;
/** /**
* Data access for the `cards` table. * Data access for the `cards` table.
* *
* `position` is a dense 0..n-1 rank *within a column* — the cards sharing a * A card either sits in its owner's inbox (project_id AND status_id both
* (project_id, status_id). The inbox is the column where status_id IS NULL. * NULL) or belongs to exactly one project with a status in it (both set) --
* enforced by a CHECK constraint. `position` is a dense 0..n-1 rank within a
* "column": the cards sharing an (owner_id, project_id, status_id).
* *
* @phpstan-type CardStatus array{id: int, name: string} * @phpstan-type CardStatus array{id: int, name: string}
* @phpstan-type CardRow array{ * @phpstan-type CardRow array{
* id: int, project_id: int, text: string, complete: bool, position: int, * id: int, owner_id: int, project_id: int|null, text: string, complete: bool, position: int,
* status_id: int|null, status: CardStatus|null, * status_id: int|null, status: CardStatus|null,
* created_at: string, updated_at: string * created_at: string, updated_at: string
* } * }
@@ -22,7 +24,7 @@ use PDO;
final class CardRepository final class CardRepository
{ {
private const SELECT = <<<'SQL' private const SELECT = <<<'SQL'
SELECT c.id, c.project_id, c.text, c.complete, c.position, c.status_id, SELECT c.id, c.owner_id, c.project_id, c.text, c.complete, c.position, c.status_id,
c.created_at, c.updated_at, c.created_at, c.updated_at,
s.name AS status_name s.name AS status_name
FROM cards c FROM cards c
@@ -34,10 +36,6 @@ final class CardRepository
} }
/** /**
* Every card in the project, grouped by column (inbox first) and ordered by
* position within each. Consumers that want a different order (e.g. the
* alphabetical "all tasks" list) re-sort client-side.
*
* @return CardRow[] * @return CardRow[]
*/ */
public function allForProject(int $projectId): array public function allForProject(int $projectId): array
@@ -51,12 +49,24 @@ final class CardRepository
} }
/** /**
* The owner's global inbox, in position order.
*
* @return CardRow[]
*/
public function allInInbox(int $ownerId): array
{
return $this->columnCards($ownerId, null, null);
}
/**
* A card owned by the caller, wherever it is (inbox or a project).
*
* @return CardRow|null * @return CardRow|null
*/ */
public function findInProject(int $id, int $projectId): ?array public function findOwnedBy(int $id, int $ownerId): ?array
{ {
$stmt = $this->pdo->prepare(self::SELECT . ' WHERE c.id = :id AND c.project_id = :project'); $stmt = $this->pdo->prepare(self::SELECT . ' WHERE c.id = :id AND c.owner_id = :owner');
$stmt->execute(['id' => $id, 'project' => $projectId]); $stmt->execute(['id' => $id, 'owner' => $ownerId]);
$row = $stmt->fetch(); $row = $stmt->fetch();
@@ -64,46 +74,44 @@ final class CardRepository
} }
/** /**
* Create a card directly in a project, at the end of the given status.
*
* @return CardRow * @return CardRow
*/ */
public function create(int $projectId, string $text, bool $complete, ?int $position): array public function createInProject(
{ int $ownerId,
// A new card has no status — it goes to the end of the inbox column. int $projectId,
$position ??= $this->nextPositionInColumn($projectId, null); int $statusId,
string $text,
bool $complete,
?int $position,
): array {
$position ??= $this->nextPositionInColumn($ownerId, $projectId, $statusId);
$stmt = $this->pdo->prepare( return $this->insert($ownerId, $projectId, $statusId, $text, $complete, $position);
'INSERT INTO cards (project_id, text, complete, position)
VALUES (:project, :text, :complete, :position)'
);
$stmt->execute([
'project' => $projectId,
'text' => $text,
'complete' => $complete ? 1 : 0,
'position' => $position,
]);
/** @var CardRow $card */
$card = $this->findInProject((int) $this->pdo->lastInsertId(), $projectId);
return $card;
} }
/** /**
* Update simple fields, and/or move the card to another column. A status * Create a card in the owner's inbox.
* change drops the card at the end of the destination column and re-packs
* the one it left. Precise slotting within a column is done via orderColumn().
* *
* @param array{text?: string, complete?: bool, status_id?: int|null} $fields
* @return CardRow * @return CardRow
*/ */
public function update(int $id, int $projectId, array $fields): array public function createInInbox(int $ownerId, string $text, bool $complete, ?int $position): array
{ {
/** @var CardRow $card */ $position ??= $this->nextPositionInColumn($ownerId, null, null);
$card = $this->findInProject($id, $projectId);
$movesColumn = array_key_exists('status_id', $fields) && $fields['status_id'] !== $card['status_id']; return $this->insert($ownerId, null, null, $text, $complete, $position);
$sourceColumn = $card['status_id']; }
/**
* Update text/complete. Moving a card between columns -- including in or
* out of the inbox -- is done via orderColumn(), not here.
*
* @param array{text?: string, complete?: bool} $fields
* @return CardRow
*/
public function update(int $id, array $fields): array
{
$sets = ['updated_at = ' . $this->nowExpr()]; $sets = ['updated_at = ' . $this->nowExpr()];
$params = ['id' => $id]; $params = ['id' => $id];
@@ -115,40 +123,13 @@ final class CardRepository
$sets[] = 'complete = :complete'; $sets[] = 'complete = :complete';
$params['complete'] = $fields['complete'] ? 1 : 0; $params['complete'] = $fields['complete'] ? 1 : 0;
} }
if (array_key_exists('status_id', $fields)) {
$sets[] = 'status_id = :status_id';
$params['status_id'] = $fields['status_id'];
}
if ($movesColumn) {
$sets[] = 'position = :position';
$params['position'] = $this->nextPositionInColumn($projectId, $fields['status_id']);
}
$sql = 'UPDATE cards SET ' . implode(', ', $sets) . ' WHERE id = :id'; $this->pdo->prepare('UPDATE cards SET ' . implode(', ', $sets) . ' WHERE id = :id')->execute($params);
if (!$movesColumn) { /** @var CardRow $card */
$this->pdo->prepare($sql)->execute($params); $card = $this->find($id);
/** @var CardRow $updated */ return $card;
$updated = $this->findInProject($id, $projectId);
return $updated;
}
$this->pdo->beginTransaction();
try {
$this->pdo->prepare($sql)->execute($params);
$this->repack($projectId, $sourceColumn);
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
/** @var CardRow $updated */
$updated = $this->findInProject($id, $projectId);
return $updated;
} }
public function delete(int $id): void public function delete(int $id): void
@@ -157,14 +138,14 @@ final class CardRepository
} }
/** /**
* IDs of every card in the project. * Every card id owned by this user, wherever it is.
* *
* @return int[] * @return int[]
*/ */
public function idsForProject(int $projectId): array public function idsOwnedBy(int $ownerId): array
{ {
$stmt = $this->pdo->prepare('SELECT id FROM cards WHERE project_id = :project'); $stmt = $this->pdo->prepare('SELECT id FROM cards WHERE owner_id = :owner');
$stmt->execute(['project' => $projectId]); $stmt->execute(['owner' => $ownerId]);
return array_map(intval(...), $stmt->fetchAll(PDO::FETCH_COLUMN)); return array_map(intval(...), $stmt->fetchAll(PDO::FETCH_COLUMN));
} }
@@ -174,50 +155,54 @@ final class CardRepository
* *
* @return int[] * @return int[]
*/ */
public function idsInColumn(int $projectId, ?int $statusId): array public function idsInColumn(int $ownerId, ?int $projectId, ?int $statusId): array
{ {
[$match, $params] = $this->columnMatch($statusId); [$match, $params] = $this->columnMatch($projectId, $statusId);
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
"SELECT id FROM cards WHERE project_id = :project AND {$match} ORDER BY position ASC, id ASC" "SELECT id FROM cards WHERE owner_id = :owner AND {$match} ORDER BY position ASC, id ASC"
); );
$stmt->execute(['project' => $projectId] + $params); $stmt->execute(['owner' => $ownerId] + $params);
return array_map(intval(...), $stmt->fetchAll(PDO::FETCH_COLUMN)); return array_map(intval(...), $stmt->fetchAll(PDO::FETCH_COLUMN));
} }
/** /**
* Set the contents and order of one column. Every id in $orderedIds is moved * Set the contents and order of one column -- the inbox (both null) or a
* into $statusId at positions 0..n-1; any other column those cards came from * project's status (both set). Every id in $orderedIds is moved there at
* is re-packed. One transaction. * positions 0..n-1; any other column those cards came from (another
* project's status, the inbox, ...) is re-packed. One transaction.
* *
* @param int[] $orderedIds * @param int[] $orderedIds
* @return CardRow[] the project's cards, grouped by column * @return CardRow[] the column's cards, in their new order
*/ */
public function orderColumn(int $projectId, ?int $statusId, array $orderedIds): array public function orderColumn(int $ownerId, ?int $projectId, ?int $statusId, array $orderedIds): array
{ {
$orderedIds = array_values($orderedIds); $orderedIds = array_values($orderedIds);
$this->pdo->beginTransaction(); $this->pdo->beginTransaction();
try { try {
$sourceColumns = $this->columnsOf($projectId, $orderedIds); $sources = $this->columnsOf($ownerId, $orderedIds);
$place = $this->pdo->prepare( $place = $this->pdo->prepare(
'UPDATE cards SET status_id = :status, position = :position, updated_at = ' . $this->nowExpr() . ' 'UPDATE cards SET project_id = :project, status_id = :status, position = :position,
WHERE id = :id AND project_id = :project' updated_at = ' . $this->nowExpr() . '
WHERE id = :id AND owner_id = :owner'
); );
foreach ($orderedIds as $position => $id) { foreach ($orderedIds as $position => $id) {
$place->execute([ $place->execute([
'project' => $projectId,
'status' => $statusId, 'status' => $statusId,
'position' => $position, 'position' => $position,
'id' => $id, 'id' => $id,
'project' => $projectId, 'owner' => $ownerId,
]); ]);
} }
foreach ($sourceColumns as $source) { foreach ($sources as [$sourceProject, $sourceStatus]) {
if ($source !== $statusId) { if ($sourceProject === $projectId && $sourceStatus === $statusId) {
$this->repack($projectId, $source); continue;
} }
$this->repack($ownerId, $sourceProject, $sourceStatus);
} }
$this->pdo->commit(); $this->pdo->commit();
@@ -226,16 +211,60 @@ final class CardRepository
throw $e; throw $e;
} }
return $this->allForProject($projectId); return $this->columnCards($ownerId, $projectId, $statusId);
}
private function insert(
int $ownerId,
?int $projectId,
?int $statusId,
string $text,
bool $complete,
int $position,
): array {
$stmt = $this->pdo->prepare(
'INSERT INTO cards (owner_id, project_id, status_id, text, complete, position)
VALUES (:owner, :project, :status, :text, :complete, :position)'
);
$stmt->execute([
'owner' => $ownerId,
'project' => $projectId,
'status' => $statusId,
'text' => $text,
'complete' => $complete ? 1 : 0,
'position' => $position,
]);
/** @var CardRow $card */
$card = $this->find((int) $this->pdo->lastInsertId());
return $card;
} }
/** /**
* The distinct status_id values (columns) the given cards currently sit in. * @return CardRow[]
*/
private function columnCards(int $ownerId, ?int $projectId, ?int $statusId): array
{
// Qualified with c. -- self::SELECT joins card_statuses, which also
// has a project_id, so the bare column name is ambiguous here.
[$match, $params] = $this->columnMatch($projectId, $statusId, 'c.');
$stmt = $this->pdo->prepare(
self::SELECT . " WHERE c.owner_id = :owner AND {$match} ORDER BY c.position ASC, c.id ASC"
);
$stmt->execute(['owner' => $ownerId] + $params);
return array_map($this->cast(...), $stmt->fetchAll());
}
/**
* The distinct (project_id, status_id) columns the given cards currently
* sit in.
* *
* @param int[] $ids * @param int[] $ids
* @return array<int, int|null> * @return list<array{0: int|null, 1: int|null}>
*/ */
private function columnsOf(int $projectId, array $ids): array private function columnsOf(int $ownerId, array $ids): array
{ {
if ($ids === []) { if ($ids === []) {
return []; return [];
@@ -243,20 +272,23 @@ final class CardRepository
$placeholders = implode(',', array_fill(0, count($ids), '?')); $placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
"SELECT DISTINCT status_id FROM cards WHERE project_id = ? AND id IN ($placeholders)" "SELECT DISTINCT project_id, status_id FROM cards WHERE owner_id = ? AND id IN ($placeholders)"
); );
$stmt->execute([$projectId, ...$ids]); $stmt->execute([$ownerId, ...$ids]);
return array_map( return array_map(
static fn ($value) => $value === null ? null : (int) $value, static fn (array $row): array => [
$stmt->fetchAll(PDO::FETCH_COLUMN), $row['project_id'] === null ? null : (int) $row['project_id'],
$row['status_id'] === null ? null : (int) $row['status_id'],
],
$stmt->fetchAll(PDO::FETCH_ASSOC),
); );
} }
/** Rewrite one column's positions to a dense 0..n-1 in current order. */ /** Rewrite one column's positions to a dense 0..n-1 in current order. */
private function repack(int $projectId, ?int $statusId): void private function repack(int $ownerId, ?int $projectId, ?int $statusId): void
{ {
$ids = $this->idsInColumn($projectId, $statusId); $ids = $this->idsInColumn($ownerId, $projectId, $statusId);
$update = $this->pdo->prepare('UPDATE cards SET position = :position WHERE id = :id'); $update = $this->pdo->prepare('UPDATE cards SET position = :position WHERE id = :id');
foreach ($ids as $position => $id) { foreach ($ids as $position => $id) {
@@ -264,28 +296,47 @@ final class CardRepository
} }
} }
private function nextPositionInColumn(int $projectId, ?int $statusId): int private function nextPositionInColumn(int $ownerId, ?int $projectId, ?int $statusId): int
{ {
[$match, $params] = $this->columnMatch($statusId); [$match, $params] = $this->columnMatch($projectId, $statusId);
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
"SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE project_id = :project AND {$match}" "SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE owner_id = :owner AND {$match}"
); );
$stmt->execute(['project' => $projectId] + $params); $stmt->execute(['owner' => $ownerId] + $params);
return (int) $stmt->fetchColumn(); return (int) $stmt->fetchColumn();
} }
/** /**
* A WHERE fragment matching one column, since SQLite needs `IS NULL` (not * A WHERE fragment matching one column. The inbox is (project_id IS NULL
* `= NULL`) for the inbox. * AND status_id IS NULL); a project column is an exact pair -- the CHECK
* constraint guarantees they're never mismatched. $prefix qualifies the
* column names (e.g. "c.") for queries that join card_statuses, which
* also has a project_id.
* *
* @return array{0: string, 1: array<string, int>} * @return array{0: string, 1: array<string, int>}
*/ */
private function columnMatch(?int $statusId): array private function columnMatch(?int $projectId, ?int $statusId, string $prefix = ''): array
{ {
return $statusId === null return $projectId === null
? ['status_id IS NULL', []] ? ["{$prefix}project_id IS NULL AND {$prefix}status_id IS NULL", []]
: ['status_id = :status', ['status' => $statusId]]; : [
"{$prefix}project_id = :project AND {$prefix}status_id = :status",
['project' => $projectId, 'status' => $statusId],
];
}
/**
* @return CardRow|null
*/
private function find(int $id): ?array
{
$stmt = $this->pdo->prepare(self::SELECT . ' WHERE c.id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row === false ? null : $this->cast($row);
} }
private function nowExpr(): string private function nowExpr(): string
@@ -300,7 +351,8 @@ final class CardRepository
private function cast(array $row): array private function cast(array $row): array
{ {
$row['id'] = (int) $row['id']; $row['id'] = (int) $row['id'];
$row['project_id'] = (int) $row['project_id']; $row['owner_id'] = (int) $row['owner_id'];
$row['project_id'] = $row['project_id'] === null ? null : (int) $row['project_id'];
$row['complete'] = (bool) $row['complete']; $row['complete'] = (bool) $row['complete'];
$row['position'] = (int) $row['position']; $row['position'] = (int) $row['position'];
-21
View File
@@ -90,27 +90,6 @@ final class Validator
return $value; return $value;
} }
/**
* An integer (>= $min) or an explicit null. Only meaningful once has()
* has confirmed the field is present; a null return is a valid value.
*/
public function nullableInt(string $field, int $min): ?int
{
if (!$this->has($field) || $this->data[$field] === null) {
return null;
}
$value = $this->data[$field];
if (!is_int($value) || $value < $min) {
$this->errors[$field][] = ucfirst($field) . " must be an integer of at least {$min}, or null.";
return null;
}
return $value;
}
public function add(string $field, string $message): void public function add(string $field, string $message): void
{ {
$this->errors[$field][] = $message; $this->errors[$field][] = $message;
+15 -4
View File
@@ -99,10 +99,21 @@ $app->group('/api', function (RouteCollectorProxy $group) use (
$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']);
$projects->put('/{projectId:[0-9]+}/cards/order', [$cardController, 'reorder']); })->add($authMiddleware);
$projects->get('/{projectId:[0-9]+}/cards/{cardId:[0-9]+}', [$cardController, 'show']);
$projects->patch('/{projectId:[0-9]+}/cards/{cardId:[0-9]+}', [$cardController, 'update']); // The inbox has no project of its own -- a user's unfiled cards.
$projects->delete('/{projectId:[0-9]+}/cards/{cardId:[0-9]+}', [$cardController, 'destroy']); $group->group('/inbox', function (RouteCollectorProxy $inbox) use ($cardController) {
$inbox->get('/cards', [$cardController, 'inboxIndex']);
$inbox->post('/cards', [$cardController, 'inboxStore']);
})->add($authMiddleware);
// Single-card and ordering routes are global: a card may not have a
// project to nest them under.
$group->group('/cards', function (RouteCollectorProxy $cards) use ($cardController) {
$cards->put('/order', [$cardController, 'reorder']);
$cards->get('/{cardId:[0-9]+}', [$cardController, 'show']);
$cards->patch('/{cardId:[0-9]+}', [$cardController, 'update']);
$cards->delete('/{cardId:[0-9]+}', [$cardController, 'destroy']);
})->add($authMiddleware); })->add($authMiddleware);
}); });
+260 -141
View File
@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace Tests; namespace Tests;
use Psr\Http\Message\ResponseInterface;
/** /**
* `position` is a dense rank within a column the cards sharing a * `position` is a dense rank within a "column" -- the cards sharing an
* (project, status). PUT /api/projects/{id}/cards/order sets one column's * (owner, project, status). The inbox is the column where project and status
* contents and order. * are both null, and it's global to the owner rather than per-project.
* PUT /api/cards/order sets one column's contents and order.
*/ */
final class CardOrderTest extends ApiTestCase final class CardOrderTest extends ApiTestCase
{ {
@@ -26,231 +29,347 @@ final class CardOrderTest extends ApiTestCase
} }
/** @param array<string, string> $auth */ /** @param array<string, string> $auth */
private function addCard(int $projectId, string $text, array $auth): int private function addToInbox(string $text, array $auth): int
{
return $this->decode(
$this->request('POST', '/api/inbox/cards', ['text' => $text], $auth),
)['card']['id'];
}
/** @param array<string, string> $auth */
private function addToProject(int $projectId, string $text, array $auth): int
{ {
return $this->decode( return $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => $text], $auth), $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => $text], $auth),
)['card']['id']; )['card']['id'];
} }
/** @param array<string, string> $auth @return list<array{text: string, status_id: int|null, position: int}> */ /** @param int[] $cardIds @param array<string, string> $auth */
private function cards(int $projectId, array $auth): array private function reorder(?int $projectId, ?int $statusId, array $cardIds, array $auth): ResponseInterface
{
return $this->request('PUT', '/api/cards/order', [
'project_id' => $projectId,
'status_id' => $statusId,
'card_ids' => $cardIds,
], $auth);
}
/** @param array<string, string> $auth @return list<array{text: string, project_id: int|null, status_id: int|null, position: int}> */
private function inbox(array $auth): array
{ {
return array_map( return array_map(
static fn (array $c): array => [ static fn (array $c): array => [
'text' => $c['text'], 'text' => $c['text'], 'project_id' => $c['project_id'],
'status_id' => $c['status_id'], 'status_id' => $c['status_id'], 'position' => $c['position'],
'position' => $c['position'],
], ],
$this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'], $this->decode($this->request('GET', '/api/inbox/cards', null, $auth))['cards'],
); );
} }
public function test_new_cards_are_ranked_densely_within_the_inbox(): void // --- inbox CRUD -----------------------------------------------------
public function test_inbox_requires_authentication(): void
{
self::assertSame(401, $this->request('GET', '/api/inbox/cards')->getStatusCode());
self::assertSame(401, $this->request('POST', '/api/inbox/cards', ['text' => 'x'])->getStatusCode());
}
public function test_creating_an_inbox_card(): void
{ {
$auth = $this->authHeader(); $auth = $this->authHeader();
$projectId = $this->newProject($auth);
$response = $this->request('POST', '/api/inbox/cards', ['text' => 'Buy milk'], $auth);
self::assertSame(201, $response->getStatusCode());
$card = $this->decode($response)['card'];
self::assertSame('Buy milk', $card['text']);
self::assertNull($card['project_id']);
self::assertNull($card['status_id']);
self::assertNull($card['status']);
self::assertSame(0, $card['position']);
}
public function test_inbox_card_creation_validates_text(): void
{
$response = $this->request('POST', '/api/inbox/cards', ['text' => ' '], $this->authHeader());
self::assertSame(422, $response->getStatusCode());
self::assertArrayHasKey('text', $this->decode($response)['error']['details']);
}
public function test_inbox_is_isolated_per_owner(): void
{
$mine = $this->authHeader('mine@example.com');
$theirs = $this->authHeader('theirs@example.com');
$this->addToInbox('mine', $mine);
$this->addToInbox('theirs', $theirs);
self::assertSame(['mine'], array_column($this->inbox($mine), 'text'));
self::assertSame(['theirs'], array_column($this->inbox($theirs), 'text'));
}
public function test_new_inbox_cards_are_ranked_densely(): void
{
$auth = $this->authHeader();
foreach (['A', 'B', 'C'] as $text) { foreach (['A', 'B', 'C'] as $text) {
$this->addCard($projectId, $text, $auth); $this->addToInbox($text, $auth);
} }
self::assertSame( self::assertSame([0, 1, 2], array_column($this->inbox($auth), 'position'));
[['text' => 'A', 'status_id' => null, 'position' => 0], self::assertSame([null, null, null], array_column($this->inbox($auth), 'project_id'));
['text' => 'B', 'status_id' => null, 'position' => 1],
['text' => 'C', 'status_id' => null, 'position' => 2]],
$this->cards($projectId, $auth),
);
} }
public function test_reorder_within_the_inbox_column(): void // --- reordering within a column --------------------------------------
public function test_reorder_within_the_inbox(): void
{ {
$auth = $this->authHeader(); $auth = $this->authHeader();
$projectId = $this->newProject($auth);
$ids = []; $ids = [];
foreach (['A', 'B', 'C'] as $text) { foreach (['A', 'B', 'C'] as $text) {
$ids[$text] = $this->addCard($projectId, $text, $auth); $ids[$text] = $this->addToInbox($text, $auth);
} }
$response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ $response = $this->reorder(null, null, [$ids['C'], $ids['A'], $ids['B']], $auth);
'status_id' => null,
'card_ids' => [$ids['C'], $ids['A'], $ids['B']],
], $auth);
self::assertSame(200, $response->getStatusCode()); self::assertSame(200, $response->getStatusCode());
self::assertSame(['C', 'A', 'B'], array_column($this->decode($response)['cards'], 'text')); self::assertSame(['C', 'A', 'B'], array_column($this->decode($response)['cards'], 'text'));
self::assertSame([0, 1, 2], array_column($this->cards($projectId, $auth), 'position')); self::assertSame(['C', 'A', 'B'], array_column($this->inbox($auth), 'text'));
} }
public function test_ordering_a_status_column_moves_cards_into_it_and_repacks_the_inbox(): void public function test_reorder_within_a_project_status(): void
{ {
$auth = $this->authHeader(); $auth = $this->authHeader();
$projectId = $this->newProject($auth); $projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id']; $todo = $this->statuses($projectId, $auth)[0]['id'];
$ids = []; $ids = [];
foreach (['A', 'B', 'C'] as $text) { foreach (['A', 'B', 'C'] as $text) {
$ids[$text] = $this->addCard($projectId, $text, $auth); $ids[$text] = $this->addToProject($projectId, $text, $auth);
} }
// Move B and C into "To do" (C first), leaving A alone in the inbox. $response = $this->reorder($projectId, $todo, [$ids['C'], $ids['A'], $ids['B']], $auth);
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$ids['C'], $ids['B']],
], $auth);
$byText = []; self::assertSame(200, $response->getStatusCode());
foreach ($this->cards($projectId, $auth) as $c) { self::assertSame(['C', 'A', 'B'], array_column($this->decode($response)['cards'], 'text'));
$byText[$c['text']] = $c;
} }
self::assertSame(['status_id' => null, 'position' => 0], ['status_id' => $byText['A']['status_id'], 'position' => $byText['A']['position']]); // --- moving between the inbox and a project --------------------------
self::assertSame(['status_id' => $todo, 'position' => 0], ['status_id' => $byText['C']['status_id'], 'position' => $byText['C']['position']]);
self::assertSame(['status_id' => $todo, 'position' => 1], ['status_id' => $byText['B']['status_id'], 'position' => $byText['B']['position']]);
}
public function test_moving_a_card_out_of_the_inbox_repacks_the_survivors(): void public function test_moving_a_card_from_the_inbox_into_a_project_status(): void
{ {
$auth = $this->authHeader(); $auth = $this->authHeader();
$projectId = $this->newProject($auth); $projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id']; $doing = $this->statuses($projectId, $auth)[1]['id'];
$ids = []; $cardId = $this->addToInbox('Triage me', $auth);
foreach (['A', 'B', 'C'] as $text) {
$ids[$text] = $this->addCard($projectId, $text, $auth); // inbox 0,1,2 $response = $this->reorder($projectId, $doing, [$cardId], $auth);
self::assertSame(200, $response->getStatusCode());
$card = $this->decode($response)['cards'][0];
self::assertSame($projectId, $card['project_id']);
self::assertSame($doing, $card['status_id']);
self::assertSame('Doing', $card['status']['name']);
self::assertSame(0, $card['position']);
self::assertSame([], $this->inbox($auth));
} }
// Pull the middle card into "To do". public function test_moving_a_card_out_of_a_project_back_to_the_inbox(): void
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [ {
'status_id' => $todo, $auth = $this->authHeader();
'card_ids' => [$ids['B']], $projectId = $this->newProject($auth);
], $auth); $cardId = $this->addToProject($projectId, 'Rethink this', $auth);
$response = $this->reorder(null, null, [$cardId], $auth);
self::assertSame(200, $response->getStatusCode());
$card = $this->decode($response)['cards'][0];
self::assertNull($card['project_id']);
self::assertNull($card['status_id']);
self::assertSame(['Rethink this'], array_column($this->inbox($auth), 'text'));
}
public function test_a_card_moves_from_one_project_to_another_via_the_inbox(): void
{
$auth = $this->authHeader();
$projectA = $this->newProject($auth, 'A');
$projectB = $this->newProject($auth, 'B');
$bStatus = $this->statuses($projectB, $auth)[2]['id'];
$cardId = $this->addToProject($projectA, 'Reassign me', $auth);
// A -> inbox
$this->reorder(null, null, [$cardId], $auth);
self::assertSame(['Reassign me'], array_column($this->inbox($auth), 'text'));
// inbox -> B
$response = $this->reorder($projectB, $bStatus, [$cardId], $auth);
self::assertSame(200, $response->getStatusCode());
$card = $this->decode($response)['cards'][0];
self::assertSame($projectB, $card['project_id']);
self::assertSame($bStatus, $card['status_id']);
self::assertSame([], $this->inbox($auth));
$projectACards = $this->decode($this->request('GET', "/api/projects/{$projectA}/cards", null, $auth))['cards'];
self::assertSame([], $projectACards);
}
public function test_moving_a_card_repacks_the_column_it_left(): void
{
$auth = $this->authHeader();
$ids = [];
foreach (['A', 'B', 'C'] as $text) {
$ids[$text] = $this->addToInbox($text, $auth); // inbox: A@0, B@1, C@2
}
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$this->reorder($projectId, $todo, [$ids['B']], $auth); // pull B out
$inbox = array_values(array_filter($this->cards($projectId, $auth), static fn ($c) => $c['status_id'] === null));
self::assertSame( self::assertSame(
[['text' => 'A', 'status_id' => null, 'position' => 0], [['text' => 'A', 'position' => 0], ['text' => 'C', 'position' => 1]],
['text' => 'C', 'status_id' => null, 'position' => 1]], array_map(
$inbox, static fn (array $c) => ['text' => $c['text'], 'position' => $c['position']],
$this->inbox($auth),
),
); );
} }
public function test_a_new_inbox_card_lands_after_the_repacked_survivors(): void public function test_a_new_inbox_card_lands_after_the_repacked_survivors(): void
{ {
$auth = $this->authHeader(); $auth = $this->authHeader();
$a = $this->addToInbox('A', $auth);
$this->addToInbox('B', $auth); // inbox: A@0, B@1
$projectId = $this->newProject($auth); $projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id']; $todo = $this->statuses($projectId, $auth)[0]['id'];
$a = $this->addCard($projectId, 'A', $auth);
$b = $this->addCard($projectId, 'B', $auth); // inbox: A@0, B@1
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [ $this->reorder($projectId, $todo, [$a], $auth); // inbox now: B@0
'status_id' => $todo,
'card_ids' => [$a],
], $auth); // inbox now: B@0
$newId = $this->addCard($projectId, 'C', $auth); $newId = $this->addToInbox('C', $auth);
$byId = []; $byId = [];
foreach ($this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'] as $c) { foreach ($this->inbox($auth) as $c) {
$byId[$c['id']] = $c; $byId[$c['text']] = $c;
} }
self::assertSame(0, $byId[$b]['position']); self::assertSame(0, $byId['B']['position']);
self::assertSame(1, $byId[$newId]['position']); self::assertSame(1, $byId['C']['position']);
self::assertNull($byId[$newId]['status_id']); self::assertNotSame($newId, null); // sanity: card was actually created
} }
public function test_patch_status_appends_the_card_to_the_destination_column(): void // --- validation --------------------------------------------------------
public function test_order_rejects_status_id_without_project_id(): void
{ {
$auth = $this->authHeader(); $auth = $this->authHeader();
$projectId = $this->newProject($auth); $projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id']; $status = $this->statuses($projectId, $auth)[0]['id'];
$first = $this->addCard($projectId, 'first', $auth); $cardId = $this->addToInbox('x', $auth);
$second = $this->addCard($projectId, 'second', $auth);
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$first}", ['status_id' => $todo], $auth); $response = $this->reorder(null, $status, [$cardId], $auth);
$moved = $this->decode(
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$second}", ['status_id' => $todo], $auth),
)['card'];
self::assertSame($todo, $moved['status_id']); self::assertSame(422, $response->getStatusCode());
self::assertSame(1, $moved['position']); // after `first`, which took slot 0
} }
public function test_reorder_rejects_a_foreign_status(): void public function test_order_requires_status_id_when_project_id_is_set(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$cardId = $this->addToInbox('x', $auth);
$response = $this->reorder($projectId, null, [$cardId], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_order_rejects_a_status_from_another_project(): void
{ {
$auth = $this->authHeader(); $auth = $this->authHeader();
$mine = $this->newProject($auth, 'Mine'); $mine = $this->newProject($auth, 'Mine');
$other = $this->newProject($auth, 'Other'); $other = $this->newProject($auth, 'Other');
$foreignStatus = $this->statuses($other, $auth)[0]['id']; $foreignStatus = $this->statuses($other, $auth)[0]['id'];
$card = $this->addCard($mine, 'x', $auth); $cardId = $this->addToProject($mine, 'x', $auth);
$response = $this->request('PUT', "/api/projects/{$mine}/cards/order", [ $response = $this->reorder($mine, $foreignStatus, [$cardId], $auth);
'status_id' => $foreignStatus,
'card_ids' => [$card],
], $auth);
self::assertSame(422, $response->getStatusCode()); self::assertSame(422, $response->getStatusCode());
} }
public function test_reorder_rejects_a_card_from_another_project(): void public function test_order_rejects_a_project_owned_by_someone_else(): void
{
$auth = $this->authHeader();
$mine = $this->newProject($auth, 'Mine');
$other = $this->newProject($auth, 'Other');
$foreignCard = $this->addCard($other, 'x', $auth);
$response = $this->request('PUT', "/api/projects/{$mine}/cards/order", [
'status_id' => null,
'card_ids' => [$foreignCard],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_must_list_every_card_already_in_the_target_column(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$a = $this->addCard($projectId, 'A', $auth);
$b = $this->addCard($projectId, 'B', $auth);
// Put both in "To do".
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$a, $b],
], $auth);
// Now try to reorder "To do" mentioning only one of them.
$response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$b],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_rejects_duplicate_ids(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$a = $this->addCard($projectId, 'A', $auth);
$response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => null,
'card_ids' => [$a, $a],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_is_scoped_to_the_owner(): void
{ {
$owner = $this->authHeader('owner@example.com'); $owner = $this->authHeader('owner@example.com');
$other = $this->authHeader('other@example.com'); $other = $this->authHeader('other@example.com');
$projectId = $this->newProject($owner); $projectId = $this->newProject($owner);
$card = $this->addCard($projectId, 'x', $owner); $status = $this->statuses($projectId, $owner)[0]['id'];
$cardId = $this->addToInbox('x', $other);
self::assertSame(404, $this->request('PUT', "/api/projects/{$projectId}/cards/order", [ $response = $this->reorder($projectId, $status, [$cardId], $other);
'status_id' => null,
'card_ids' => [$card], self::assertSame(404, $response->getStatusCode());
], $other)->getStatusCode()); }
public function test_order_rejects_a_card_owned_by_someone_else(): void
{
$owner = $this->authHeader('owner@example.com');
$other = $this->authHeader('other@example.com');
$foreignCardId = $this->addToInbox('not yours', $owner);
$response = $this->reorder(null, null, [$foreignCardId], $other);
self::assertSame(422, $response->getStatusCode());
}
public function test_order_must_list_every_card_already_in_the_target_column(): void
{
$auth = $this->authHeader();
$a = $this->addToInbox('A', $auth);
$this->addToInbox('B', $auth);
$response = $this->reorder(null, null, [$a], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_order_rejects_duplicate_ids(): void
{
$auth = $this->authHeader();
$a = $this->addToInbox('A', $auth);
$response = $this->reorder(null, null, [$a, $a], $auth);
self::assertSame(422, $response->getStatusCode());
}
// --- single-card routes are global -------------------------------------
public function test_a_card_is_only_reachable_by_its_owner(): void
{
$owner = $this->authHeader('owner@example.com');
$other = $this->authHeader('other@example.com');
$cardId = $this->addToInbox('mine', $owner);
self::assertSame(200, $this->request('GET', "/api/cards/{$cardId}", null, $owner)->getStatusCode());
self::assertSame(404, $this->request('GET', "/api/cards/{$cardId}", null, $other)->getStatusCode());
self::assertSame(404, $this->request('PATCH', "/api/cards/{$cardId}", ['text' => 'x'], $other)->getStatusCode());
self::assertSame(404, $this->request('DELETE', "/api/cards/{$cardId}", null, $other)->getStatusCode());
}
public function test_patch_requires_a_recognised_field(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$status = $this->statuses($projectId, $auth)[1]['id'];
$cardId = $this->addToProject($projectId, 'x', $auth);
// status_id is no longer a PATCH field -- moves go through /cards/order.
$response = $this->request('PATCH', "/api/cards/{$cardId}", ['status_id' => $status], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_deleting_an_inbox_card(): void
{
$auth = $this->authHeader();
$cardId = $this->addToInbox('gone soon', $auth);
self::assertSame(204, $this->request('DELETE', "/api/cards/{$cardId}", null, $auth)->getStatusCode());
self::assertSame([], $this->inbox($auth));
} }
} }
+13 -70
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Tests; namespace Tests;
use PDOException;
final class CardStatusTest extends ApiTestCase final class CardStatusTest extends ApiTestCase
{ {
/** Create a project and return its id. */ /** Create a project and return its id. */
@@ -63,93 +65,34 @@ final class CardStatusTest extends ApiTestCase
self::assertSame(404, $this->request('GET', "/api/projects/{$projectId}/statuses", null, $other)->getStatusCode()); self::assertSame(404, $this->request('GET', "/api/projects/{$projectId}/statuses", null, $other)->getStatusCode());
} }
public function test_a_new_card_has_no_status(): void public function test_a_card_created_directly_in_a_project_starts_in_its_first_status(): void
{ {
$auth = $this->authHeader(); $auth = $this->authHeader();
$projectId = $this->newProject($auth); $projectId = $this->newProject($auth);
$firstStatus = $this->statuses($projectId, $auth)[0];
$card = $this->decode( $card = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'First'], $auth), $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'First'], $auth),
)['card']; )['card'];
// New cards sit in the "inbox" — no status until the user assigns one. self::assertSame($firstStatus['id'], $card['status_id']);
self::assertArrayHasKey('status_id', $card); self::assertSame('To do', $card['status']['name']);
self::assertNull($card['status_id']); self::assertSame($projectId, $card['project_id']);
self::assertNull($card['status']);
} }
public function test_a_card_can_be_moved_between_statuses_and_back_to_the_inbox(): void public function test_a_referenced_status_cannot_be_deleted(): 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.
$auth = $this->authHeader(); $auth = $this->authHeader();
$projectId = $this->newProject($auth); $projectId = $this->newProject($auth);
$statuses = $this->statuses($projectId, $auth);
$cardId = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'Move me'], $auth),
)['card']['id'];
$doing = $this->decode(
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$cardId}", ['status_id' => $statuses[1]['id']], $auth),
)['card'];
self::assertSame($statuses[1]['id'], $doing['status_id']);
self::assertSame('Doing', $doing['status']['name']);
$backToInbox = $this->decode(
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$cardId}", ['status_id' => null], $auth),
)['card'];
self::assertNull($backToInbox['status_id']);
self::assertNull($backToInbox['status']);
}
public function test_a_card_rejects_a_status_from_another_project(): void
{
$auth = $this->authHeader();
$mine = $this->newProject($auth, 'Mine');
$other = $this->newProject($auth, 'Other');
$foreignStatusId = $this->statuses($other, $auth)[0]['id'];
$cardId = $this->decode(
$this->request('POST', "/api/projects/{$mine}/cards", ['text' => 'x'], $auth),
)['card']['id'];
$response = $this->request('PATCH', "/api/projects/{$mine}/cards/{$cardId}", ['status_id' => $foreignStatusId], $auth);
self::assertSame(422, $response->getStatusCode());
self::assertArrayHasKey('status_id', $this->decode($response)['error']['details']);
}
public function test_a_card_rejects_an_unknown_status(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$cardId = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $auth),
)['card']['id'];
$response = $this->request('PATCH', "/api/projects/{$projectId}/cards/{$cardId}", ['status_id' => 999999], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_deleting_a_status_clears_it_from_its_cards(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$cardId = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'Orphan me'], $auth),
)['card']['id'];
$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('PATCH', "/api/projects/{$projectId}/cards/{$cardId}", ['status_id' => $statusId], $auth);
// No delete endpoint for statuses yet — remove the row directly to
// exercise ON DELETE SET NULL.
$this->db()->exec('PRAGMA foreign_keys = ON'); $this->db()->exec('PRAGMA foreign_keys = ON');
$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]);
$reread = $this->decode(
$this->request('GET', "/api/projects/{$projectId}/cards/{$cardId}", null, $auth),
)['card'];
self::assertNull($reread['status_id']);
self::assertNull($reread['status']);
} }
public function test_deleting_a_project_cascades_to_its_statuses(): void public function test_deleting_a_project_cascades_to_its_statuses(): void
+8 -69
View File
@@ -100,12 +100,15 @@ final class ProjectTest extends ApiTestCase
self::assertSame(404, $this->request('GET', "/api/projects/{$projectId}", null, $auth)->getStatusCode()); self::assertSame(404, $this->request('GET', "/api/projects/{$projectId}", null, $auth)->getStatusCode());
} }
public function test_cards_append_in_order_and_track_completion(): void public function test_cards_land_in_the_first_status_and_track_completion(): void
{ {
$auth = $this->authHeader(); $auth = $this->authHeader();
$projectId = $this->decode( $projectId = $this->decode(
$this->request('POST', '/api/projects', ['title' => 'Chores'], $auth), $this->request('POST', '/api/projects', ['title' => 'Chores'], $auth),
)['project']['id']; )['project']['id'];
$firstStatus = $this->decode(
$this->request('GET', "/api/projects/{$projectId}/statuses", null, $auth),
)['statuses'][0];
foreach (['Wash up', 'Hoover', 'Bins'] as $text) { foreach (['Wash up', 'Hoover', 'Bins'] as $text) {
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => $text], $auth); $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => $text], $auth);
@@ -114,10 +117,11 @@ final class ProjectTest extends ApiTestCase
$cards = $this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards']; $cards = $this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'];
self::assertSame(['Wash up', 'Hoover', 'Bins'], array_column($cards, 'text')); self::assertSame(['Wash up', 'Hoover', 'Bins'], array_column($cards, 'text'));
self::assertSame([0, 1, 2], array_column($cards, 'position')); self::assertSame([0, 1, 2], array_column($cards, 'position'));
self::assertSame([$firstStatus['id'], $firstStatus['id'], $firstStatus['id']], array_column($cards, 'status_id'));
self::assertFalse($cards[0]['complete']); self::assertFalse($cards[0]['complete']);
$done = $this->decode( $done = $this->decode(
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$cards[0]['id']}", ['complete' => true], $auth), $this->request('PATCH', "/api/cards/{$cards[0]['id']}", ['complete' => true], $auth),
)['card']; )['card'];
self::assertTrue($done['complete']); self::assertTrue($done['complete']);
@@ -143,68 +147,6 @@ final class ProjectTest extends ApiTestCase
self::assertArrayHasKey('text', $this->decode($bad)['error']['details']); self::assertArrayHasKey('text', $this->decode($bad)['error']['details']);
} }
public function test_cards_can_be_reordered_in_bulk(): void
{
$auth = $this->authHeader();
$projectId = $this->decode(
$this->request('POST', '/api/projects', ['title' => 'Reorder'], $auth),
)['project']['id'];
$ids = [];
foreach (['A', 'B', 'C'] as $text) {
$ids[$text] = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => $text], $auth),
)['card']['id'];
}
$response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'card_ids' => [$ids['C'], $ids['A'], $ids['B']],
], $auth);
self::assertSame(200, $response->getStatusCode());
$cards = $this->decode($response)['cards'];
self::assertSame(['C', 'A', 'B'], array_column($cards, 'text'));
self::assertSame([0, 1, 2], array_column($cards, 'position'));
// Order persists on a fresh read.
$reread = $this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'];
self::assertSame(['C', 'A', 'B'], array_column($reread, 'text'));
}
public function test_reorder_rejects_an_incomplete_id_set(): void
{
$auth = $this->authHeader();
$projectId = $this->decode(
$this->request('POST', '/api/projects', ['title' => 'Reorder'], $auth),
)['project']['id'];
$first = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'one'], $auth),
)['card']['id'];
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'two'], $auth);
$response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'card_ids' => [$first],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_is_scoped_to_the_owner(): void
{
$owner = $this->authHeader('ro@example.com');
$other = $this->authHeader('rx@example.com');
$projectId = $this->decode(
$this->request('POST', '/api/projects', ['title' => 'Mine'], $owner),
)['project']['id'];
$cardId = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $owner),
)['card']['id'];
self::assertSame(404, $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'card_ids' => [$cardId],
], $other)->getStatusCode());
}
public function test_deleting_a_project_cascades_to_its_cards(): void public function test_deleting_a_project_cascades_to_its_cards(): void
{ {
$auth = $this->authHeader(); $auth = $this->authHeader();
@@ -217,11 +159,8 @@ final class ProjectTest extends ApiTestCase
$this->request('DELETE', "/api/projects/{$projectId}", null, $auth); $this->request('DELETE', "/api/projects/{$projectId}", null, $auth);
// The parent project is gone, so the card route 404s on the project check. // The card went with its project.
self::assertSame( self::assertSame(404, $this->request('GET', "/api/cards/{$cardId}", null, $auth)->getStatusCode());
404,
$this->request('GET', "/api/projects/{$projectId}/cards/{$cardId}", null, $auth)->getStatusCode(),
);
} }
public function test_cards_under_another_users_project_are_not_reachable(): void public function test_cards_under_another_users_project_are_not_reachable(): void
+35 -21
View File
@@ -33,11 +33,13 @@ src/main.ts App bootstrap; resolves the stored session before mount
src/router/index.ts Routes + guard (redirects to /login when unauthenticated) src/router/index.ts Routes + guard (redirects to /login when unauthenticated)
src/stores/auth.ts Pinia store: token in localStorage, magic-link + fetchMe src/stores/auth.ts Pinia store: token in localStorage, magic-link + fetchMe
src/stores/projects.ts Pinia store: the user's projects (fetch + create) src/stores/projects.ts Pinia store: the user's projects (fetch + create)
src/stores/cards.ts Pinia store: one project's cards (CRUD + reorderColumn) src/stores/cards.ts Pinia store: one project's cards (CRUD; no reordering -- see lib/cardOrder.ts)
src/stores/inbox.ts Pinia store: the caller's global inbox (fetch + create)
src/lib/api.ts fetch wrapper, bearer token, typed ApiError src/lib/api.ts fetch wrapper, bearer token, typed ApiError
src/components/AppSidebar.vue left nav: Dashboard link, divider, project list + new-project form src/lib/cardOrder.ts reorderColumn() -- PUT /api/cards/order, shared by the sidebar and kanban board
src/components/AppSidebar.vue left nav: Dashboard link, project list + form, Inbox + form
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 src/components/KanbanCard.vue small draggable card for the board columns and the inbox
src/views/ DashboardView, ProjectView, LoginView, ProfileView, src/views/ DashboardView, ProjectView, LoginView, ProfileView,
VerifyEmailView VerifyEmailView
``` ```
@@ -45,15 +47,27 @@ src/views/ DashboardView, ProjectView, LoginView, ProfileView,
Signed-in "app" routes (`meta.requiresAuth`) render inside a persistent shell: Signed-in "app" routes (`meta.requiresAuth`) render inside a persistent shell:
the top bar, then a left **sidebar** (`AppSidebar.vue`) beside the routed view. the top bar, then a left **sidebar** (`AppSidebar.vue`) beside the routed view.
The sidebar stays mounted across navigation — it holds a **Dashboard** link, a The sidebar stays mounted across navigation — it holds a **Dashboard** link, a
divider, the project list, and a compact new-project form (creating one jumps to divider, the project list + new-project form, another divider, then the
it). The current page is highlighted via RouterLink's `active-class`. **Inbox** (see below). The current page is highlighted via RouterLink's
`<RouterView :key="route.path">` remounts the view on every path change so `active-class`. `<RouterView :key="route.path">` remounts the view on every
sidebar → project → project navigation always does a fresh load. path change so sidebar → project → project navigation always does a fresh load.
`/` redirects to `/dashboard` (`DashboardView.vue`), a full-width grid of `/` redirects to `/dashboard` (`DashboardView.vue`), a full-width grid linking
project cards — each shows the project name and, under a **New** heading, its to each project, showing its title and card count. Signed-out routes (`/login`,
inbox cards (`status_id === null`), fetched per project. Signed-out routes `/verify-email`) render without the sidebar.
(`/login`, `/verify-email`) render without the sidebar.
## Inbox
A card with no project lives in the caller's inbox (`useInboxStore`), rendered
in the sidebar under the project list -- not per-project, and not tied to
whatever page is open. It's a `vuedraggable` list in the same `"kanban"` drag
group as every project's kanban columns (below), so a card can be dragged
straight out of the sidebar into any status column of whichever project is
currently open, or the other way. `AppSidebar`'s `onInboxChange` persists a
drop via `reorderColumn(null, null, ids)`, then reloads the inbox and, if a
project view is currently mounted (`route.name === 'project'`), that project's
cards too -- either side of a drag could have been the inbox. A small form
under the list adds a card straight to the inbox.
## Project detail ## Project detail
@@ -76,18 +90,18 @@ delete button.
### Kanban ### Kanban
Columns, left to right: **Inbox** (cards with no status) then each project One column per project status, in `position` order -- the inbox is *not* a
status in `position` order. `board` is derived from `cards.cards` + the column here; it's in the sidebar (see above), though it's still a valid drag
project's statuses and rebuilt by a `watch` whenever either changes. source/target. `board` is derived from `cards.cards` + the project's statuses
and rebuilt by a `watch` whenever either changes.
Every drop — whether reordering within a column (`moved`) or dragging in from Every drop — whether reordering within a column (`moved`) or dragging in from
another (`added`) — calls `cards.reorderColumn(column.statusId, ids)` another column or the sidebar's inbox (`added`) — calls
`PUT /api/projects/:id/cards/order` with `{ status_id, card_ids }`. The server `reorderColumn(projectId, column.statusId, ids)` from `lib/cardOrder.ts` (shared
re-parents any moved-in card, re-packs the source column, and returns the whole with the sidebar) → `PUT /api/cards/order`. The server re-parents any moved-in
project's cards, which replaces local state; on failure the board reloads. 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
The Inbox column has a small name + **Add** form at the bottom (`cards.add`); side of the move.
new cards have no status, so they land straight in it.
## Auth flow ## Auth flow
+3
View File
@@ -4,11 +4,13 @@ import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
import AppSidebar from './components/AppSidebar.vue' import AppSidebar from './components/AppSidebar.vue'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
import { useCardsStore } from './stores/cards' import { useCardsStore } from './stores/cards'
import { useInboxStore } from './stores/inbox'
import { useProjectsStore } from './stores/projects' import { useProjectsStore } from './stores/projects'
const auth = useAuthStore() const auth = useAuthStore()
const projects = useProjectsStore() const projects = useProjectsStore()
const cards = useCardsStore() const cards = useCardsStore()
const inbox = useInboxStore()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -20,6 +22,7 @@ async function onLogout() {
auth.logout() auth.logout()
projects.reset() projects.reset()
cards.reset() cards.reset()
inbox.reset()
await router.push({ name: 'login' }) await router.push({ name: 'login' })
} }
</script> </script>
+103 -3
View File
@@ -1,11 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router' import { RouterLink, useRoute, useRouter } from 'vue-router'
import draggable from 'vuedraggable'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { reorderColumn } from '../lib/cardOrder'
import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox'
import { MAX_PROJECTS, useProjectsStore } from '../stores/projects' import { MAX_PROJECTS, useProjectsStore } from '../stores/projects'
import type { Card } from '../types'
import KanbanCard from './KanbanCard.vue'
const projects = useProjectsStore() const projects = useProjectsStore()
const cards = useCardsStore()
const inbox = useInboxStore()
const router = useRouter() const router = useRouter()
const route = useRoute()
const title = ref('') const title = ref('')
const createError = ref<ApiError | null>(null) const createError = ref<ApiError | null>(null)
@@ -15,10 +24,11 @@ const loadError = ref<string | null>(null)
const atLimit = computed(() => projects.projects.length >= MAX_PROJECTS) const atLimit = computed(() => projects.projects.length >= MAX_PROJECTS)
onMounted(() => { onMounted(() => {
if (!projects.loaded) void load() if (!projects.loaded) void loadProjects()
if (!inbox.loaded) void loadInbox()
}) })
async function load() { async function loadProjects() {
loadError.value = null loadError.value = null
try { try {
await projects.fetchProjects() await projects.fetchProjects()
@@ -40,6 +50,58 @@ async function onCreate() {
submitting.value = false submitting.value = false
} }
} }
// --- Inbox: a global holding area, not tied to any project. Cards drag in
// and out of it from a project's kanban board (shared "kanban" group).
const newInboxText = ref('')
const addingToInbox = ref(false)
const inboxError = ref<string | null>(null)
type ColumnChange = {
added?: { element: Card; newIndex: number }
moved?: { element: Card; oldIndex: number; newIndex: number }
}
async function loadInbox() {
inboxError.value = null
try {
await inbox.load()
} catch (e) {
inboxError.value = e instanceof ApiError ? e.message : 'Could not load the inbox.'
}
}
async function onCreateInboxCard() {
if (!newInboxText.value.trim()) return
addingToInbox.value = true
inboxError.value = null
try {
await inbox.add(newInboxText.value)
newInboxText.value = ''
} catch (e) {
inboxError.value = e instanceof ApiError ? e.message : 'Could not add the card.'
} finally {
addingToInbox.value = false
}
}
async function onInboxChange(change: ColumnChange) {
if (!change.added && !change.moved) return
inboxError.value = null
try {
await reorderColumn(null, null, inbox.cards.map((c) => c.id))
} catch (e) {
inboxError.value = e instanceof ApiError ? e.message : 'Something went wrong.'
} finally {
// The card may have come from (or gone to) the project currently open.
const openProjectId = route.name === 'project' ? Number(route.params.id) : null
await Promise.all([
loadInbox(),
openProjectId !== null ? cards.load(openProjectId) : Promise.resolve(),
])
}
}
</script> </script>
<template> <template>
@@ -105,5 +167,43 @@ async function onCreate() {
</button> </button>
<small v-if="atLimit" class="hint">Limit of {{ MAX_PROJECTS }} projects reached.</small> <small v-if="atLimit" class="hint">Limit of {{ MAX_PROJECTS }} projects reached.</small>
</form> </form>
<hr class="sidebar__divider" />
<p class="sidebar__heading">Inbox</p>
<p v-if="inboxError" class="sidebar__note form-error">{{ inboxError }}</p>
<p v-else-if="inbox.loading && !inbox.loaded" class="sidebar__note muted">Loading</p>
<template v-else>
<!-- Always rendered (even empty) so it stays a valid drop target for a
card dragged out of a project's kanban board. -->
<p v-if="inbox.cards.length === 0" class="sidebar__note muted">Nothing in the inbox.</p>
<draggable
:list="inbox.cards"
:group="{ name: 'kanban' }"
item-key="id"
class="kanban__cards sidebar__inbox-cards"
ghost-class="kanban-card--ghost"
:animation="150"
@change="onInboxChange"
>
<template #item="{ element }: { element: Card }">
<KanbanCard :card="element" />
</template>
</draggable>
</template>
<form class="kanban__new" @submit.prevent="onCreateInboxCard">
<input
v-model="newInboxText"
type="text"
maxlength="1000"
required
placeholder="New card"
aria-label="New card"
/>
<button type="submit" :disabled="addingToInbox">Add</button>
</form>
</aside> </aside>
</template> </template>
+21
View File
@@ -0,0 +1,21 @@
import { apiRequest } from './api'
import type { Card } from '../types'
/**
* Set the contents and order of one column -- the inbox (both null) or a
* project's status (both set). Shared by the sidebar's inbox list and a
* project's kanban columns, since either can be a drag source or target for
* the other (dragging a card in re-parents it; its old column is re-packed
* server-side).
*/
export async function reorderColumn(
projectId: number | null,
statusId: number | null,
cardIds: number[],
): Promise<{ cards: Card[] }> {
return apiRequest<{ cards: Card[] }>('/cards/order', {
method: 'PUT',
auth: true,
body: { project_id: projectId, status_id: statusId, card_ids: cardIds },
})
}
+10 -21
View File
@@ -6,8 +6,10 @@ import type { Card } from '../types'
type CardPatch = Partial<Pick<Card, 'text' | 'complete'>> type CardPatch = Partial<Pick<Card, 'text' | 'complete'>>
export const useCardsStore = defineStore('cards', () => { export const useCardsStore = defineStore('cards', () => {
// Every card in the project, grouped by column (inbox first) then position. // One project's cards, grouped by status then position. Views re-sort as
// Views re-sort as needed (the "all tasks" list is alphabetical). // needed (the "all tasks" list is alphabetical). Moving a card in or out of
// this project (including via the inbox) goes through lib/cardOrder.ts,
// not this store -- callers re-load() afterwards.
const cards = ref<Card[]>([]) const cards = ref<Card[]>([])
const projectId = ref<number | null>(null) const projectId = ref<number | null>(null)
const loading = ref(false) const loading = ref(false)
@@ -41,10 +43,11 @@ export const useCardsStore = defineStore('cards', () => {
} }
async function patch(card: Card, fields: CardPatch): Promise<void> { async function patch(card: Card, fields: CardPatch): Promise<void> {
const { card: updated } = await apiRequest<{ card: Card }>( const { card: updated } = await apiRequest<{ card: Card }>(`/cards/${card.id}`, {
`/projects/${projectId.value}/cards/${card.id}`, method: 'PATCH',
{ method: 'PATCH', auth: true, body: fields }, auth: true,
) body: fields,
})
const i = cards.value.findIndex((x) => x.id === updated.id) const i = cards.value.findIndex((x) => x.id === updated.id)
if (i !== -1) cards.value[i] = updated if (i !== -1) cards.value[i] = updated
} }
@@ -53,23 +56,10 @@ export const useCardsStore = defineStore('cards', () => {
const setText = (card: Card, text: string) => patch(card, { text }) const setText = (card: Card, text: string) => patch(card, { text })
async function remove(card: Card): Promise<void> { async function remove(card: Card): Promise<void> {
await apiRequest(`/projects/${projectId.value}/cards/${card.id}`, { method: 'DELETE', auth: true }) await apiRequest(`/cards/${card.id}`, { method: 'DELETE', auth: true })
cards.value = cards.value.filter((c) => c.id !== card.id) cards.value = cards.value.filter((c) => c.id !== card.id)
} }
/**
* Set the contents and order of one status column (`null` = inbox). Cards
* dragged in from another column are re-parented server-side; the response is
* the whole project's cards, which replaces local state.
*/
async function reorderColumn(statusId: number | null, cardIds: number[]): Promise<void> {
const { cards: fresh } = await apiRequest<{ cards: Card[] }>(
`/projects/${projectId.value}/cards/order`,
{ method: 'PUT', auth: true, body: { status_id: statusId, card_ids: cardIds } },
)
cards.value = fresh
}
function reset(): void { function reset(): void {
cards.value = [] cards.value = []
projectId.value = null projectId.value = null
@@ -87,7 +77,6 @@ export const useCardsStore = defineStore('cards', () => {
setComplete, setComplete,
setText, setText,
remove, remove,
reorderColumn,
reset, reset,
} }
}) })
+43
View File
@@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { apiRequest } from '../lib/api'
import type { Card } from '../types'
/**
* The signed-in user's global inbox: cards with no project. Loaded once and
* kept mounted in the sidebar for the whole session, so it stays visible (and
* a drag target/source) while navigating between the dashboard and projects.
*/
export const useInboxStore = defineStore('inbox', () => {
const cards = ref<Card[]>([])
const loaded = ref(false)
const loading = ref(false)
async function load(): Promise<void> {
loading.value = true
try {
const { cards: fetched } = await apiRequest<{ cards: Card[] }>('/inbox/cards', { auth: true })
cards.value = fetched
loaded.value = true
} finally {
loading.value = false
}
}
async function add(text: string): Promise<void> {
const { card } = await apiRequest<{ card: Card }>('/inbox/cards', {
method: 'POST',
auth: true,
body: { text },
})
// The API appends the card, so the end of the array is its correct place.
cards.value.push(card)
}
function reset(): void {
cards.value = []
loaded.value = false
}
return { cards, loaded, loading, load, add, reset }
})
+15 -38
View File
@@ -163,6 +163,12 @@ body {
overflow-y: auto; overflow-y: auto;
} }
.sidebar__inbox-cards {
max-height: 40vh;
overflow-y: auto;
padding: 0 0.1rem; /* room for the ghost card's outline while dragging */
}
.sidebar__note { .sidebar__note {
padding: 0 0.6rem; padding: 0 0.6rem;
font-size: 0.85rem; font-size: 0.85rem;
@@ -219,12 +225,12 @@ h1 {
font-size: 0.9rem; font-size: 0.9rem;
} }
/* --- dashboard: grid of projects with their inbox ------------------- */ /* --- dashboard: grid of projects --------------------------------------- */
.dashboard__grid { .dashboard__grid {
margin-top: 1rem; margin-top: 1rem;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 1rem; gap: 1rem;
align-items: start; align-items: start;
} }
@@ -237,50 +243,21 @@ h1 {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
color: inherit;
text-decoration: none;
}
.project-card:hover {
border-color: var(--accent);
} }
.project-card__title { .project-card__title {
font-weight: 600; font-weight: 600;
font-size: 1.05rem; font-size: 1.05rem;
color: inherit;
text-decoration: none;
word-break: break-word; word-break: break-word;
} }
.project-card__title:hover { .project-card__meta {
color: var(--accent);
}
.project-card__heading {
margin: 0.3rem 0 0;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.project-card__cards {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
max-height: 16rem;
overflow-y: auto;
}
.project-card__cards li {
font-size: 0.9rem;
padding: 0.35rem 0.5rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
}
.project-card__empty {
margin: 0;
font-size: 0.85rem; font-size: 0.85rem;
} }
+5 -1
View File
@@ -31,9 +31,13 @@ export interface CardStatus {
name: string name: string
} }
/**
* A card either sits in its owner's global inbox (project_id and status_id
* both null) or belongs to one project with a status in it (both set).
*/
export interface Card { export interface Card {
id: number id: number
project_id: number project_id: number | null
text: string text: string
complete: boolean complete: boolean
position: number position: number
+11 -30
View File
@@ -1,37 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { ApiError, apiRequest } from '../lib/api' import { ApiError } from '../lib/api'
import { useProjectsStore } from '../stores/projects' import { useProjectsStore } from '../stores/projects'
import type { Card } from '../types'
const projects = useProjectsStore() const projects = useProjectsStore()
const loading = ref(true)
const loadError = ref<string | null>(null) const loadError = ref<string | null>(null)
/** project id -> its inbox cards (status_id === null) */
const inbox = ref<Record<number, Card[]>>({})
onMounted(load) onMounted(load)
async function load() { async function load() {
loading.value = true
loadError.value = null loadError.value = null
try { try {
await projects.fetchProjects() await projects.fetchProjects()
const entries = await Promise.all(
projects.projects.map(async (project): Promise<[number, Card[]]> => {
const { cards } = await apiRequest<{ cards: Card[] }>(`/projects/${project.id}/cards`, {
auth: true,
})
return [project.id, cards.filter((card) => card.status_id === null)]
}),
)
inbox.value = Object.fromEntries(entries)
} catch (e) { } catch (e) {
loadError.value = e instanceof ApiError ? e.message : 'Could not load the dashboard.' loadError.value = e instanceof ApiError ? e.message : 'Could not load the dashboard.'
} finally {
loading.value = false
} }
} }
</script> </script>
@@ -39,27 +23,24 @@ async function load() {
<template> <template>
<div class="dashboard"> <div class="dashboard">
<p v-if="loadError" class="form-error">{{ loadError }}</p> <p v-if="loadError" class="form-error">{{ loadError }}</p>
<p v-else-if="loading" class="muted">Loading</p> <p v-else-if="projects.loading && !projects.loaded" class="muted">Loading</p>
<p v-else-if="projects.projects.length === 0" class="muted"> <p v-else-if="projects.projects.length === 0" class="muted">
No projects yet create one from the sidebar. No projects yet create one from the sidebar. Anything uncategorised
lives in the inbox, also in the sidebar.
</p> </p>
<div v-else class="dashboard__grid"> <div v-else class="dashboard__grid">
<article v-for="project in projects.projects" :key="project.id" class="project-card">
<RouterLink <RouterLink
v-for="project in projects.projects"
:key="project.id"
:to="{ name: 'project', params: { id: project.id } }" :to="{ name: 'project', params: { id: project.id } }"
class="project-card__title" class="project-card"
> >
{{ project.title }} <span class="project-card__title">{{ project.title }}</span>
<span class="project-card__meta muted">
{{ project.card_count }} card{{ project.card_count === 1 ? '' : 's' }}
</span>
</RouterLink> </RouterLink>
<p class="project-card__heading">New</p>
<ul v-if="inbox[project.id]?.length" class="project-card__cards">
<li v-for="card in inbox[project.id]" :key="card.id">{{ card.text }}</li>
</ul>
<p v-else class="project-card__empty muted">Nothing new.</p>
</article>
</div> </div>
</div> </div>
</template> </template>
+27 -54
View File
@@ -5,13 +5,16 @@ 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 { ApiError, apiRequest } from '../lib/api' import { ApiError, apiRequest } from '../lib/api'
import { reorderColumn } from '../lib/cardOrder'
import { useCardsStore } from '../stores/cards' import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox'
import { useProjectsStore } from '../stores/projects' 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 router = useRouter()
const cards = useCardsStore() const cards = useCardsStore()
const inbox = useInboxStore()
const projects = useProjectsStore() const projects = useProjectsStore()
const projectId = Number(route.params.id) const projectId = Number(route.params.id)
@@ -38,9 +41,6 @@ const cancelButton = ref<HTMLButtonElement>()
const newText = ref('') const newText = ref('')
const submitting = ref(false) const submitting = ref(false)
const newInboxText = ref('')
const addingToInbox = ref(false)
const summary = computed(() => { const summary = computed(() => {
const total = cards.cards.length const total = cards.cards.length
if (total === 0) return 'No cards yet.' if (total === 0) return 'No cards yet.'
@@ -53,10 +53,12 @@ const sortedCards = computed(() =>
) )
// --- Kanban board -------------------------------------------------------- // --- Kanban board --------------------------------------------------------
// One column per project status -- the inbox lives in the sidebar now, not
// here, though it's still a valid drag source/target (shared "kanban" group).
interface Column { interface Column {
key: string key: string
title: string title: string
statusId: number | null statusId: number
cards: Card[] cards: Card[]
} }
type ColumnChange = { type ColumnChange = {
@@ -68,13 +70,11 @@ type ColumnChange = {
const board = ref<Column[]>([]) const board = ref<Column[]>([])
function buildColumns(): Column[] { function buildColumns(): Column[] {
const defs: Omit<Column, 'cards'>[] = [ return statuses.value.map((s) => ({
{ key: 'inbox', title: 'Inbox', statusId: null }, key: `status-${s.id}`,
...statuses.value.map((s) => ({ key: `status-${s.id}`, title: s.name, statusId: s.id })), title: s.name,
] statusId: s.id,
return defs.map((def) => ({ cards: cards.cards.filter((card) => card.status_id === s.id),
...def,
cards: cards.cards.filter((card) => card.status_id === def.statusId),
})) }))
} }
@@ -86,12 +86,21 @@ function rebuildBoard() {
// (e.g. after a move is persisted, or a failed move is rolled back). // (e.g. after a move is persisted, or a failed move is rolled back).
watch([() => cards.cards, statuses], rebuildBoard, { deep: true }) watch([() => cards.cards, statuses], rebuildBoard, { deep: true })
function onColumnChange(change: ColumnChange, column: Column) { async function onColumnChange(change: ColumnChange, column: Column) {
// `added` (card dragged in from another column) or `moved` (reordered within // `added` (card dragged in, from another column here or from the sidebar's
// this one): persist this column's new id order. The source column, if any, // inbox) or `moved` (reordered within this one): persist this column's new
// is re-packed server-side. `removed` needs no action here. // id order. The column it left -- another status, or the inbox -- is
if (change.added || change.moved) { // re-packed server-side. `removed` needs no action here.
void run(cards.reorderColumn(column.statusId, column.cards.map((c) => c.id))) if (!change.added && !change.moved) return
actionError.value = null
try {
await reorderColumn(projectId, column.statusId, column.cards.map((c) => c.id))
} catch (e) {
actionError.value = e instanceof ApiError ? e.message : 'Something went wrong.'
} finally {
// Either side of the drag could have been the inbox, so refresh both.
await Promise.all([inbox.load(), cards.load(projectId)])
} }
} }
@@ -218,21 +227,6 @@ async function onCreate() {
submitting.value = false submitting.value = false
} }
} }
// New cards always land in the inbox (no status), so this just adds one.
async function onCreateInbox() {
if (!newInboxText.value.trim()) return
addingToInbox.value = true
actionError.value = null
try {
await cards.add(newInboxText.value)
newInboxText.value = ''
} catch (e) {
actionError.value = e instanceof ApiError ? e.message : 'Could not add the card.'
} finally {
addingToInbox.value = false
}
}
</script> </script>
<template> <template>
@@ -344,12 +338,7 @@ async function onCreateInbox() {
<p v-if="cards.loading && !cards.loaded" class="muted">Loading</p> <p v-if="cards.loading && !cards.loaded" class="muted">Loading</p>
<div v-else class="kanban"> <div v-else class="kanban">
<section <section v-for="column in board" :key="column.key" class="kanban__col">
v-for="column in board"
:key="column.key"
class="kanban__col"
:class="{ 'kanban__col--inbox': column.statusId === null }"
>
<header class="kanban__head"> <header class="kanban__head">
<span class="kanban__title">{{ column.title }}</span> <span class="kanban__title">{{ column.title }}</span>
<span class="kanban__count">{{ column.cards.length }}</span> <span class="kanban__count">{{ column.cards.length }}</span>
@@ -368,22 +357,6 @@ async function onCreateInbox() {
<KanbanCard :card="element" /> <KanbanCard :card="element" />
</template> </template>
</draggable> </draggable>
<form
v-if="column.statusId === null"
class="kanban__new"
@submit.prevent="onCreateInbox"
>
<input
v-model="newInboxText"
type="text"
maxlength="1000"
required
placeholder="New card"
aria-label="New card"
/>
<button type="submit" :disabled="addingToInbox">Add</button>
</form>
</section> </section>
</div> </div>
</div> </div>