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 |
| 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 |
| 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 |
| 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
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
Scoped to a project; the parent project's ownership is checked first
(`404` otherwise).
A card either sits in its owner's **inbox** (`project_id` and `status_id` both
`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 |
|--------|------|---------|
| `GET` | `/api/projects/{id}/cards` | every card, grouped by column (inbox first) then `position` |
| `POST` | `/api/projects/{id}/cards` | add a card |
| `PUT` | `/api/projects/{id}/cards/order` | set the order/contents of one status column |
| `GET` | `/api/projects/{id}/cards/{cardId}` | one card |
| `PATCH` | `/api/projects/{id}/cards/{cardId}` | update `text`, `complete`, and/or `status_id` |
| `DELETE` | `/api/projects/{id}/cards/{cardId}` | delete the card (`204`) |
| `GET` | `/api/projects/{id}/cards` | a project's cards, grouped by status then `position` |
| `POST` | `/api/projects/{id}/cards` | add a card directly to the project (its first status) |
| `GET` | `/api/inbox/cards` | the caller's inbox |
| `POST` | `/api/inbox/cards` | add a card to the inbox |
| `GET` \| `PATCH` \| `DELETE` | `/api/cards/{cardId}` | one card, owner-scoped (`404` otherwise) |
| `PUT` | `/api/cards/order` | set the order/contents of one column |
Create body: `text` (required, 11000 chars), `complete` (optional bool,
default `false`). `PATCH` needs at least one field.
Create body (either creation route): `text` (required, 11000 chars),
`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
that share a `(project_id, status_id)`. The inbox (`status_id IS NULL`) is its
own column. New cards go to the end of the inbox. There is no project-wide order.
that share an `(owner, project, status)`. The inbox is its own column, per
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
"inbox" until the user gives it one. Two ways to move it:
```json
{ "project_id": 5, "status_id": 12, "card_ids": [3, 1, 2] }
```
- `PATCH …/cards/{cardId}` with `status_id` (a status id in this project, or
`null` for the inbox) — appends the card to the end of the destination column
and re-packs the one it left. `422` for an unknown or foreign status.
- `PUT …/cards/order` with `{ "status_id": <id|null>, "card_ids": [3, 1, 2] }`
makes those cards the exact contents of that column, in that order (positions
rewritten to `0..n-1`). Any card dragged in from another column is re-parented
and its old column re-packed, all in one transaction. `card_ids` must be
distinct cards of this project and must include every card already in the
target column (`422` otherwise). Returns `{ "cards": [ … ] }` for the whole
project. This is what the kanban board calls on every drop.
A status row that is deleted clears itself from its cards rather than deleting
them.
`project_id`/`status_id` are both `null` for the inbox, or both set to a
project owned by the caller and one of its statuses (`404`/`422` otherwise).
`card_ids` must be distinct cards owned by the caller and must include every
card already in the target column (`422` otherwise); it rewrites positions to
`0..n-1`. Any card in the list that wasn't already in that column is
re-parented into it — moving it from another project's status, or the inbox,
or vice versa — and the column it left is re-packed, all in one transaction.
Returns `{ "cards": [ … ] }` for the new column. This is what dragging a card
in the kanban board (or the sidebar's inbox) calls on every drop; moving a
card from one project to another is just two calls, via the inbox in between.
Card representation:
@@ -304,23 +308,25 @@ Card representation:
"text": "Design homepage",
"complete": false,
"position": 0,
"status_id": null,
"status": null,
"status_id": 2,
"status": { "id": 2, "name": "Doing" },
"created_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
card has none. `GET …/cards` returns `{ "cards": [ … ] }`.
`project_id` and `status_id` are `null` together for an inbox card. `status` is
the embedded `{ id, name }` of the linked status, or `null`. `GET …/cards`
returns `{ "cards": [ … ] }`.
### Statuses
Every project has an ordered set of card statuses, created with the project:
"To do", "Doing", "Done". They are project-specific — each project owns its own
rows. There is no create/update/delete for the statuses themselves yet; a card
is moved between them (or to the inbox) via `PATCH …/cards/{cardId}`.
rows. There is no create/update/delete for the statuses themselves yet, and (as
a project card must always have one) a referenced status can't be deleted at
the database level either.
| 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;
/**
* CRUD for the cards within one project. Every route first checks that the
* parent project is owned by the authenticated user; otherwise it responds 404.
* CRUD for cards. A card either sits in the caller's inbox (no project) or
* 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
{
@@ -41,6 +43,9 @@ final class CardController extends Controller
/**
* 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
{
@@ -52,29 +57,62 @@ final class CardController extends Controller
$position = $validator->optionalInt('position', 0);
$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);
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
{
$projectId = $this->requireOwnedProjectId($request, $args);
$card = $this->requireCard($projectId, $args);
$card = $this->requireCard($request, $args);
$validator = new Validator($this->body($request));
$fields = [];
@@ -84,63 +122,51 @@ final class CardController extends Controller
if ($validator->has('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()) {
$validator->add('text', 'Provide at least one of: text, complete, status_id.');
$validator->add('text', 'Provide at least one of: text, complete.');
}
$validator->assert();
$updated = $this->cards->update($card['id'], $projectId, $fields);
$this->projects->touch($projectId);
$updated = $this->cards->update($card['id'], $fields);
if ($card['project_id'] !== null) {
$this->projects->touch($card['project_id']);
}
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
{
$projectId = $this->requireOwnedProjectId($request, $args);
$this->cards->delete($this->requireCard($projectId, $args)['id']);
$this->projects->touch($projectId);
$card = $this->requireCard($request, $args);
$this->cards->delete($card['id']);
if ($card['project_id'] !== null) {
$this->projects->touch($card['project_id']);
}
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
* should make up that column, in order. Positions are rewritten to 0..n-1;
* any card moved in from another column is re-parented and its old column
* re-packed. `status_id` is omitted or null for the inbox.
* Body: { "project_id": 5 | null, "status_id": 2 | null, "card_ids": [3, 1, 2] }
* -- both null for the inbox, or both set to a project owned by the
* caller and one of its statuses. `card_ids` are the cards that should
* 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);
$statusId = null;
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);
}
}
[$projectId, $statusId] = $this->targetColumn($ownerId, $body);
$order = $body['card_ids'] ?? null;
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))) {
throw new ApiException('card_ids must not contain duplicates.', 422);
}
if (array_diff($order, $this->cards->idsForProject($projectId)) !== []) {
throw new ApiException('Every card_id must be a card in this project.', 422);
if (array_diff($order, $this->cards->idsOwnedBy($ownerId)) !== []) {
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);
}
$cards = $this->cards->orderColumn($projectId, $statusId, $order);
$cards = $this->cards->orderColumn($ownerId, $projectId, $statusId, $order);
if ($projectId !== null) {
$this->projects->touch($projectId);
}
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
*/
@@ -179,11 +250,11 @@ final class CardController extends Controller
/**
* @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) {
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>
*/
private function present(array $card): array
+163 -111
View File
@@ -9,12 +9,14 @@ use PDO;
/**
* Data access for the `cards` table.
*
* `position` is a dense 0..n-1 rank *within a column* — the cards sharing a
* (project_id, status_id). The inbox is the column where status_id IS NULL.
* 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. `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 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,
* created_at: string, updated_at: string
* }
@@ -22,7 +24,7 @@ use PDO;
final class CardRepository
{
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,
s.name AS status_name
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[]
*/
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
*/
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->execute(['id' => $id, 'project' => $projectId]);
$stmt = $this->pdo->prepare(self::SELECT . ' WHERE c.id = :id AND c.owner_id = :owner');
$stmt->execute(['id' => $id, 'owner' => $ownerId]);
$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
*/
public function create(int $projectId, string $text, bool $complete, ?int $position): array
{
// A new card has no status — it goes to the end of the inbox column.
$position ??= $this->nextPositionInColumn($projectId, null);
public function createInProject(
int $ownerId,
int $projectId,
int $statusId,
string $text,
bool $complete,
?int $position,
): array {
$position ??= $this->nextPositionInColumn($ownerId, $projectId, $statusId);
$stmt = $this->pdo->prepare(
'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;
return $this->insert($ownerId, $projectId, $statusId, $text, $complete, $position);
}
/**
* Update simple fields, and/or move the card to another column. A status
* 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().
* Create a card in the owner's inbox.
*
* @param array{text?: string, complete?: bool, status_id?: int|null} $fields
* @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 */
$card = $this->findInProject($id, $projectId);
$position ??= $this->nextPositionInColumn($ownerId, null, null);
$movesColumn = array_key_exists('status_id', $fields) && $fields['status_id'] !== $card['status_id'];
$sourceColumn = $card['status_id'];
return $this->insert($ownerId, null, null, $text, $complete, $position);
}
/**
* 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()];
$params = ['id' => $id];
@@ -115,40 +123,13 @@ final class CardRepository
$sets[] = 'complete = :complete';
$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) {
$this->pdo->prepare($sql)->execute($params);
/** @var CardRow $card */
$card = $this->find($id);
/** @var CardRow $updated */
$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;
return $card;
}
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[]
*/
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->execute(['project' => $projectId]);
$stmt = $this->pdo->prepare('SELECT id FROM cards WHERE owner_id = :owner');
$stmt->execute(['owner' => $ownerId]);
return array_map(intval(...), $stmt->fetchAll(PDO::FETCH_COLUMN));
}
@@ -174,50 +155,54 @@ final class CardRepository
*
* @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(
"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));
}
/**
* Set the contents and order of one column. Every id in $orderedIds is moved
* into $statusId at positions 0..n-1; any other column those cards came from
* is re-packed. One transaction.
* Set the contents and order of one column -- the inbox (both null) or a
* project's status (both set). Every id in $orderedIds is moved there at
* 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
* @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);
$this->pdo->beginTransaction();
try {
$sourceColumns = $this->columnsOf($projectId, $orderedIds);
$sources = $this->columnsOf($ownerId, $orderedIds);
$place = $this->pdo->prepare(
'UPDATE cards SET status_id = :status, position = :position, updated_at = ' . $this->nowExpr() . '
WHERE id = :id AND project_id = :project'
'UPDATE cards SET project_id = :project, status_id = :status, position = :position,
updated_at = ' . $this->nowExpr() . '
WHERE id = :id AND owner_id = :owner'
);
foreach ($orderedIds as $position => $id) {
$place->execute([
'project' => $projectId,
'status' => $statusId,
'position' => $position,
'id' => $id,
'project' => $projectId,
'owner' => $ownerId,
]);
}
foreach ($sourceColumns as $source) {
if ($source !== $statusId) {
$this->repack($projectId, $source);
foreach ($sources as [$sourceProject, $sourceStatus]) {
if ($sourceProject === $projectId && $sourceStatus === $statusId) {
continue;
}
$this->repack($ownerId, $sourceProject, $sourceStatus);
}
$this->pdo->commit();
@@ -226,16 +211,60 @@ final class CardRepository
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
* @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 === []) {
return [];
@@ -243,20 +272,23 @@ final class CardRepository
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$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(
static fn ($value) => $value === null ? null : (int) $value,
$stmt->fetchAll(PDO::FETCH_COLUMN),
static fn (array $row): array => [
$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. */
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');
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(
"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();
}
/**
* A WHERE fragment matching one column, since SQLite needs `IS NULL` (not
* `= NULL`) for the inbox.
* A WHERE fragment matching one column. The inbox is (project_id IS NULL
* 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>}
*/
private function columnMatch(?int $statusId): array
private function columnMatch(?int $projectId, ?int $statusId, string $prefix = ''): array
{
return $statusId === null
? ['status_id IS NULL', []]
: ['status_id = :status', ['status' => $statusId]];
return $projectId === null
? ["{$prefix}project_id IS NULL AND {$prefix}status_id IS NULL", []]
: [
"{$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
@@ -300,7 +351,8 @@ final class CardRepository
private function cast(array $row): array
{
$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['position'] = (int) $row['position'];
-21
View File
@@ -90,27 +90,6 @@ final class Validator
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
{
$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->post('/{projectId:[0-9]+}/cards', [$cardController, 'store']);
$projects->put('/{projectId:[0-9]+}/cards/order', [$cardController, 'reorder']);
$projects->get('/{projectId:[0-9]+}/cards/{cardId:[0-9]+}', [$cardController, 'show']);
$projects->patch('/{projectId:[0-9]+}/cards/{cardId:[0-9]+}', [$cardController, 'update']);
$projects->delete('/{projectId:[0-9]+}/cards/{cardId:[0-9]+}', [$cardController, 'destroy']);
})->add($authMiddleware);
// The inbox has no project of its own -- a user's unfiled cards.
$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);
});
+260 -141
View File
@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace Tests;
use Psr\Http\Message\ResponseInterface;
/**
* `position` is a dense rank within a column the cards sharing a
* (project, status). PUT /api/projects/{id}/cards/order sets one column's
* contents and order.
* `position` is a dense rank within a "column" -- the cards sharing an
* (owner, project, status). The inbox is the column where project and status
* 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
{
@@ -26,231 +29,347 @@ final class CardOrderTest extends ApiTestCase
}
/** @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(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => $text], $auth),
)['card']['id'];
}
/** @param array<string, string> $auth @return list<array{text: string, status_id: int|null, position: int}> */
private function cards(int $projectId, array $auth): array
/** @param int[] $cardIds @param array<string, string> $auth */
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(
static fn (array $c): array => [
'text' => $c['text'],
'status_id' => $c['status_id'],
'position' => $c['position'],
'text' => $c['text'], 'project_id' => $c['project_id'],
'status_id' => $c['status_id'], '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();
$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) {
$this->addCard($projectId, $text, $auth);
$this->addToInbox($text, $auth);
}
self::assertSame(
[['text' => 'A', 'status_id' => null, 'position' => 0],
['text' => 'B', 'status_id' => null, 'position' => 1],
['text' => 'C', 'status_id' => null, 'position' => 2]],
$this->cards($projectId, $auth),
);
self::assertSame([0, 1, 2], array_column($this->inbox($auth), 'position'));
self::assertSame([null, null, null], array_column($this->inbox($auth), 'project_id'));
}
public function test_reorder_within_the_inbox_column(): void
// --- reordering within a column --------------------------------------
public function test_reorder_within_the_inbox(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$ids = [];
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", [
'status_id' => null,
'card_ids' => [$ids['C'], $ids['A'], $ids['B']],
], $auth);
$response = $this->reorder(null, null, [$ids['C'], $ids['A'], $ids['B']], $auth);
self::assertSame(200, $response->getStatusCode());
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();
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$ids = [];
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.
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$ids['C'], $ids['B']],
], $auth);
$response = $this->reorder($projectId, $todo, [$ids['C'], $ids['A'], $ids['B']], $auth);
$byText = [];
foreach ($this->cards($projectId, $auth) as $c) {
$byText[$c['text']] = $c;
self::assertSame(200, $response->getStatusCode());
self::assertSame(['C', 'A', 'B'], array_column($this->decode($response)['cards'], 'text'));
}
self::assertSame(['status_id' => null, 'position' => 0], ['status_id' => $byText['A']['status_id'], 'position' => $byText['A']['position']]);
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']]);
}
// --- moving between the inbox and a project --------------------------
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();
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$ids = [];
foreach (['A', 'B', 'C'] as $text) {
$ids[$text] = $this->addCard($projectId, $text, $auth); // inbox 0,1,2
$doing = $this->statuses($projectId, $auth)[1]['id'];
$cardId = $this->addToInbox('Triage me', $auth);
$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".
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$ids['B']],
], $auth);
public function test_moving_a_card_out_of_a_project_back_to_the_inbox(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($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(
[['text' => 'A', 'status_id' => null, 'position' => 0],
['text' => 'C', 'status_id' => null, 'position' => 1]],
$inbox,
[['text' => 'A', 'position' => 0], ['text' => 'C', 'position' => 1]],
array_map(
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
{
$auth = $this->authHeader();
$a = $this->addToInbox('A', $auth);
$this->addToInbox('B', $auth); // inbox: A@0, B@1
$projectId = $this->newProject($auth);
$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", [
'status_id' => $todo,
'card_ids' => [$a],
], $auth); // inbox now: B@0
$this->reorder($projectId, $todo, [$a], $auth); // inbox now: B@0
$newId = $this->addCard($projectId, 'C', $auth);
$newId = $this->addToInbox('C', $auth);
$byId = [];
foreach ($this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'] as $c) {
$byId[$c['id']] = $c;
foreach ($this->inbox($auth) as $c) {
$byId[$c['text']] = $c;
}
self::assertSame(0, $byId[$b]['position']);
self::assertSame(1, $byId[$newId]['position']);
self::assertNull($byId[$newId]['status_id']);
self::assertSame(0, $byId['B']['position']);
self::assertSame(1, $byId['C']['position']);
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();
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$first = $this->addCard($projectId, 'first', $auth);
$second = $this->addCard($projectId, 'second', $auth);
$status = $this->statuses($projectId, $auth)[0]['id'];
$cardId = $this->addToInbox('x', $auth);
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$first}", ['status_id' => $todo], $auth);
$moved = $this->decode(
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$second}", ['status_id' => $todo], $auth),
)['card'];
$response = $this->reorder(null, $status, [$cardId], $auth);
self::assertSame($todo, $moved['status_id']);
self::assertSame(1, $moved['position']); // after `first`, which took slot 0
self::assertSame(422, $response->getStatusCode());
}
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();
$mine = $this->newProject($auth, 'Mine');
$other = $this->newProject($auth, 'Other');
$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", [
'status_id' => $foreignStatus,
'card_ids' => [$card],
], $auth);
$response = $this->reorder($mine, $foreignStatus, [$cardId], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_rejects_a_card_from_another_project(): 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
public function test_order_rejects_a_project_owned_by_someone_else(): void
{
$owner = $this->authHeader('owner@example.com');
$other = $this->authHeader('other@example.com');
$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", [
'status_id' => null,
'card_ids' => [$card],
], $other)->getStatusCode());
$response = $this->reorder($projectId, $status, [$cardId], $other);
self::assertSame(404, $response->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;
use PDOException;
final class CardStatusTest extends ApiTestCase
{
/** 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());
}
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();
$projectId = $this->newProject($auth);
$firstStatus = $this->statuses($projectId, $auth)[0];
$card = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'First'], $auth),
)['card'];
// New cards sit in the "inbox" — no status until the user assigns one.
self::assertArrayHasKey('status_id', $card);
self::assertNull($card['status_id']);
self::assertNull($card['status']);
self::assertSame($firstStatus['id'], $card['status_id']);
self::assertSame('To do', $card['status']['name']);
self::assertSame($projectId, $card['project_id']);
}
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();
$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'];
$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->expectException(PDOException::class);
$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
+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());
}
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();
$projectId = $this->decode(
$this->request('POST', '/api/projects', ['title' => 'Chores'], $auth),
)['project']['id'];
$firstStatus = $this->decode(
$this->request('GET', "/api/projects/{$projectId}/statuses", null, $auth),
)['statuses'][0];
foreach (['Wash up', 'Hoover', 'Bins'] as $text) {
$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'];
self::assertSame(['Wash up', 'Hoover', 'Bins'], array_column($cards, 'text'));
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']);
$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'];
self::assertTrue($done['complete']);
@@ -143,68 +147,6 @@ final class ProjectTest extends ApiTestCase
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
{
$auth = $this->authHeader();
@@ -217,11 +159,8 @@ final class ProjectTest extends ApiTestCase
$this->request('DELETE', "/api/projects/{$projectId}", null, $auth);
// The parent project is gone, so the card route 404s on the project check.
self::assertSame(
404,
$this->request('GET', "/api/projects/{$projectId}/cards/{$cardId}", null, $auth)->getStatusCode(),
);
// The card went with its project.
self::assertSame(404, $this->request('GET', "/api/cards/{$cardId}", null, $auth)->getStatusCode());
}
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/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/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/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/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,
VerifyEmailView
```
@@ -45,15 +47,27 @@ src/views/ DashboardView, ProjectView, LoginView, ProfileView,
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 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
it). The current page is highlighted via RouterLink's `active-class`.
`<RouterView :key="route.path">` remounts the view on every path change so
sidebar → project → project navigation always does a fresh load.
divider, the project list + new-project form, another divider, then the
**Inbox** (see below). The current page is highlighted via RouterLink's
`active-class`. `<RouterView :key="route.path">` remounts the view on every
path change so sidebar → project → project navigation always does a fresh load.
`/` redirects to `/dashboard` (`DashboardView.vue`), a full-width grid of
project cards — each shows the project name and, under a **New** heading, its
inbox cards (`status_id === null`), fetched per project. Signed-out routes
(`/login`, `/verify-email`) render without the sidebar.
`/` redirects to `/dashboard` (`DashboardView.vue`), a full-width grid linking
to each project, showing its title and card count. Signed-out routes (`/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
@@ -76,18 +90,18 @@ delete button.
### Kanban
Columns, left to right: **Inbox** (cards with no status) then each project
status in `position` order. `board` is derived from `cards.cards` + the
project's statuses and rebuilt by a `watch` whenever either changes.
One column per project status, in `position` order -- the inbox is *not* a
column here; it's in the sidebar (see above), though it's still a valid drag
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
another (`added`) — calls `cards.reorderColumn(column.statusId, ids)`
`PUT /api/projects/:id/cards/order` with `{ status_id, card_ids }`. The server
re-parents any moved-in card, re-packs the source column, and returns the whole
project's cards, which replaces local state; on failure the board reloads.
The Inbox column has a small name + **Add** form at the bottom (`cards.add`);
new cards have no status, so they land straight in it.
another column or the sidebar's inbox (`added`) — calls
`reorderColumn(projectId, column.statusId, ids)` from `lib/cardOrder.ts` (shared
with the sidebar) → `PUT /api/cards/order`. The server re-parents any moved-in
card and re-packs whatever column it left; afterwards the view always reloads
both `inbox` and this project's `cards`, since either could have been the other
side of the move.
## Auth flow
+3
View File
@@ -4,11 +4,13 @@ import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
import AppSidebar from './components/AppSidebar.vue'
import { useAuthStore } from './stores/auth'
import { useCardsStore } from './stores/cards'
import { useInboxStore } from './stores/inbox'
import { useProjectsStore } from './stores/projects'
const auth = useAuthStore()
const projects = useProjectsStore()
const cards = useCardsStore()
const inbox = useInboxStore()
const router = useRouter()
const route = useRoute()
@@ -20,6 +22,7 @@ async function onLogout() {
auth.logout()
projects.reset()
cards.reset()
inbox.reset()
await router.push({ name: 'login' })
}
</script>
+103 -3
View File
@@ -1,11 +1,20 @@
<script setup lang="ts">
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 { reorderColumn } from '../lib/cardOrder'
import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox'
import { MAX_PROJECTS, useProjectsStore } from '../stores/projects'
import type { Card } from '../types'
import KanbanCard from './KanbanCard.vue'
const projects = useProjectsStore()
const cards = useCardsStore()
const inbox = useInboxStore()
const router = useRouter()
const route = useRoute()
const title = ref('')
const createError = ref<ApiError | null>(null)
@@ -15,10 +24,11 @@ const loadError = ref<string | null>(null)
const atLimit = computed(() => projects.projects.length >= MAX_PROJECTS)
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
try {
await projects.fetchProjects()
@@ -40,6 +50,58 @@ async function onCreate() {
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>
<template>
@@ -105,5 +167,43 @@ async function onCreate() {
</button>
<small v-if="atLimit" class="hint">Limit of {{ MAX_PROJECTS }} projects reached.</small>
</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>
</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'>>
export const useCardsStore = defineStore('cards', () => {
// Every card in the project, grouped by column (inbox first) then position.
// Views re-sort as needed (the "all tasks" list is alphabetical).
// One project's cards, grouped by status then position. Views re-sort as
// 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 projectId = ref<number | null>(null)
const loading = ref(false)
@@ -41,10 +43,11 @@ export const useCardsStore = defineStore('cards', () => {
}
async function patch(card: Card, fields: CardPatch): Promise<void> {
const { card: updated } = await apiRequest<{ card: Card }>(
`/projects/${projectId.value}/cards/${card.id}`,
{ method: 'PATCH', auth: true, body: fields },
)
const { card: updated } = await apiRequest<{ card: Card }>(`/cards/${card.id}`, {
method: 'PATCH',
auth: true,
body: fields,
})
const i = cards.value.findIndex((x) => x.id === updated.id)
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 })
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)
}
/**
* 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 {
cards.value = []
projectId.value = null
@@ -87,7 +77,6 @@ export const useCardsStore = defineStore('cards', () => {
setComplete,
setText,
remove,
reorderColumn,
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;
}
.sidebar__inbox-cards {
max-height: 40vh;
overflow-y: auto;
padding: 0 0.1rem; /* room for the ghost card's outline while dragging */
}
.sidebar__note {
padding: 0 0.6rem;
font-size: 0.85rem;
@@ -219,12 +225,12 @@ h1 {
font-size: 0.9rem;
}
/* --- dashboard: grid of projects with their inbox ------------------- */
/* --- dashboard: grid of projects --------------------------------------- */
.dashboard__grid {
margin-top: 1rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 1rem;
align-items: start;
}
@@ -237,50 +243,21 @@ h1 {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
color: inherit;
text-decoration: none;
}
.project-card:hover {
border-color: var(--accent);
}
.project-card__title {
font-weight: 600;
font-size: 1.05rem;
color: inherit;
text-decoration: none;
word-break: break-word;
}
.project-card__title:hover {
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;
.project-card__meta {
font-size: 0.85rem;
}
+5 -1
View File
@@ -31,9 +31,13 @@ export interface CardStatus {
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 {
id: number
project_id: number
project_id: number | null
text: string
complete: boolean
position: number
+11 -30
View File
@@ -1,37 +1,21 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { ApiError, apiRequest } from '../lib/api'
import { ApiError } from '../lib/api'
import { useProjectsStore } from '../stores/projects'
import type { Card } from '../types'
const projects = useProjectsStore()
const loading = ref(true)
const loadError = ref<string | null>(null)
/** project id -> its inbox cards (status_id === null) */
const inbox = ref<Record<number, Card[]>>({})
onMounted(load)
async function load() {
loading.value = true
loadError.value = null
try {
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) {
loadError.value = e instanceof ApiError ? e.message : 'Could not load the dashboard.'
} finally {
loading.value = false
}
}
</script>
@@ -39,27 +23,24 @@ async function load() {
<template>
<div class="dashboard">
<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">
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>
<div v-else class="dashboard__grid">
<article v-for="project in projects.projects" :key="project.id" class="project-card">
<RouterLink
v-for="project in projects.projects"
:key="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>
<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>
</template>
+27 -54
View File
@@ -5,13 +5,16 @@ import draggable from 'vuedraggable'
import CardRow from '../components/CardRow.vue'
import KanbanCard from '../components/KanbanCard.vue'
import { ApiError, apiRequest } from '../lib/api'
import { reorderColumn } from '../lib/cardOrder'
import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox'
import { useProjectsStore } from '../stores/projects'
import type { Card, CardStatus, Project } from '../types'
const route = useRoute()
const router = useRouter()
const cards = useCardsStore()
const inbox = useInboxStore()
const projects = useProjectsStore()
const projectId = Number(route.params.id)
@@ -38,9 +41,6 @@ const cancelButton = ref<HTMLButtonElement>()
const newText = ref('')
const submitting = ref(false)
const newInboxText = ref('')
const addingToInbox = ref(false)
const summary = computed(() => {
const total = cards.cards.length
if (total === 0) return 'No cards yet.'
@@ -53,10 +53,12 @@ const sortedCards = computed(() =>
)
// --- 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 {
key: string
title: string
statusId: number | null
statusId: number
cards: Card[]
}
type ColumnChange = {
@@ -68,13 +70,11 @@ type ColumnChange = {
const board = ref<Column[]>([])
function buildColumns(): Column[] {
const defs: Omit<Column, 'cards'>[] = [
{ key: 'inbox', title: 'Inbox', statusId: null },
...statuses.value.map((s) => ({ key: `status-${s.id}`, title: s.name, statusId: s.id })),
]
return defs.map((def) => ({
...def,
cards: cards.cards.filter((card) => card.status_id === def.statusId),
return statuses.value.map((s) => ({
key: `status-${s.id}`,
title: s.name,
statusId: s.id,
cards: cards.cards.filter((card) => card.status_id === s.id),
}))
}
@@ -86,12 +86,21 @@ function rebuildBoard() {
// (e.g. after a move is persisted, or a failed move is rolled back).
watch([() => cards.cards, statuses], rebuildBoard, { deep: true })
function onColumnChange(change: ColumnChange, column: Column) {
// `added` (card dragged in from another column) or `moved` (reordered within
// this one): persist this column's new id order. The source column, if any,
// is re-packed server-side. `removed` needs no action here.
if (change.added || change.moved) {
void run(cards.reorderColumn(column.statusId, column.cards.map((c) => c.id)))
async function onColumnChange(change: ColumnChange, column: Column) {
// `added` (card dragged in, from another column here or from the sidebar's
// inbox) or `moved` (reordered within this one): persist this column's new
// id order. The column it left -- another status, or the inbox -- is
// re-packed server-side. `removed` needs no action here.
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
}
}
// 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>
<template>
@@ -344,12 +338,7 @@ async function onCreateInbox() {
<p v-if="cards.loading && !cards.loaded" class="muted">Loading</p>
<div v-else class="kanban">
<section
v-for="column in board"
:key="column.key"
class="kanban__col"
:class="{ 'kanban__col--inbox': column.statusId === null }"
>
<section v-for="column in board" :key="column.key" class="kanban__col">
<header class="kanban__head">
<span class="kanban__title">{{ column.title }}</span>
<span class="kanban__count">{{ column.cards.length }}</span>
@@ -368,22 +357,6 @@ async function onCreateInbox() {
<KanbanCard :card="element" />
</template>
</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>
</div>
</div>