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:
@@ -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, 1–1000 chars), `complete` (optional bool,
|
Create body (either creation route): `text` (required, 1–1000 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 |
|
||||||
|--------|------|---------|
|
|--------|------|---------|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
@@ -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'];
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user