Add per-project card statuses and a kanban board

Statuses
- Migration 006: card_statuses table (project-scoped) and cards.status_id, a
  nullable FK with ON DELETE SET NULL. Every new project is seeded with
  "To do" / "Doing" / "Done"; GET /api/projects/{id}/statuses lists them.
- New cards have no status -- they sit in an "inbox" until moved.

Project view
- Full-width and tabbed: "All tasks" (a flat list, sorted by name
  case-insensitively) and "Kanban" (Inbox plus one column per status).
- Drag a card within or between columns to reorder / restatus; the Inbox
  column has its own name + Add form.

Ordering
- Migration 007: `position` is now a dense 0..n-1 rank within a
  (project_id, status_id) column, not a project-wide order. New composite
  index idx_cards_project_status_position; existing rows re-ranked.
- PUT /api/projects/{id}/cards/order takes { status_id, card_ids } and sets one
  column's contents and order, re-parenting moved-in cards and re-packing their
  source column in a single transaction. PATCH status_id appends the card to the
  end of the destination column.

58 phpunit tests pass; the frontend type-checks and builds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 13:37:17 +01:00
co-authored by Claude Sonnet 5
parent d9db4a3a30
commit c47c800d01
21 changed files with 1389 additions and 239 deletions
+85 -38
View File
@@ -16,6 +16,8 @@ Each user owns **projects**, and each project holds ordered **cards**.
| 6 | Project view — inline title/description editing, delete via a Manage menu | ✅ done | | 6 | Project view — inline title/description editing, delete via a Manage menu | ✅ done |
| 7 | Email verification (magic links) + profile page (resend, change email) | ✅ done | | 7 | Email verification (magic links) + profile page (resend, change email) | ✅ done |
| 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 |
| 10 | Project view — full-width, tabbed: alphabetical "All tasks" list + "Kanban" board, per-column drag ordering | ✅ done |
Registration signs the user in immediately and emails a magic link that verifies Registration signs the user in immediately and emails a magic link that verifies
the address; `user.email_verified` stays `false` until the link is opened. See the address; `user.email_verified` stays `false` until the link is opened. See
@@ -29,22 +31,24 @@ The only requirement is Docker with the Compose plugin.
docker compose up -d docker compose up -d
``` ```
This builds a PHP 8.3 + Apache image, applies migrations, and serves the API at This runs a multi-stage build — a Node stage compiles the Vue frontend, then a
<http://localhost:8080> (e.g. `curl http://localhost:8080/api/health`). PHP 8.3 + Apache stage bakes in the PHP source and the built SPA — applies
migrations, and serves the whole app at <http://localhost:8080>: the SPA at `/`
(assets and all) and the REST API under `/api` (e.g.
`curl http://localhost:8080/api/health`). Unknown paths fall back to the SPA
shell for client-side routing.
- A **[Mailpit](https://mailpit.axllent.org/)** container (the maintained MailHog - A **[Mailpit](https://mailpit.axllent.org/)** container (the maintained MailHog
successor — one ~15 MB Go binary, messages kept in memory) also starts. The API successor — one ~15 MB Go binary, messages kept in memory) also starts. The API
sends all email to it; read it at <http://localhost:8025>. Set sends all email to it; read it at <http://localhost:8025>. Set
`MAIL_TRANSPORT=mail` or `=smtp` (with `MAIL_SMTP_*`) to send for real. `MAIL_TRANSPORT=mail` or `=smtp` (with `MAIL_SMTP_*`) to send for real.
- The project directory is bind-mounted into the container, so editing PHP - The image is the artifact: PHP source and the compiled frontend are copied in
source takes effect without a rebuild (within ~2s, due to the opcache at build time, not bind-mounted. Rebuild to pick up any code change:
revalidation interval). `vendor/` is used from the host — run `composer` `docker compose up -d --build`. For iterating on the frontend, run the Vite
once first if it is missing (see "Run without Docker" below, or dev server on the host instead (see [Frontend](#frontend)).
`docker compose run --rm --entrypoint composer app install`).
- The SQLite database and the generated JWT signing key live in the `storage` - The SQLite database and the generated JWT signing key live in the `storage`
named volume, mounted at `/var/www/storage` (outside the bind-mounted source), named volume, mounted at `/var/www/storage`, so they survive
so they survive `docker compose restart` / `down` + `up`. `docker compose restart` / `down` + `up`.
- Rebuild only after changing the `Dockerfile`: `docker compose up -d --build`.
- `docker compose down -v` removes the volume and gives you a clean database. - `docker compose down -v` removes the volume and gives you a clean database.
- Override settings via the environment or a `.env` file in this directory - Override settings via the environment or a `.env` file in this directory
(Compose substitutes `APP_DEBUG`, `JWT_SECRET`, `JWT_TTL` — see (Compose substitutes `APP_DEBUG`, `JWT_SECRET`, `JWT_TTL` — see
@@ -73,13 +77,17 @@ composer migrate # creates storage/database.sqlite and its tables
composer serve # http://localhost:8080 (php -S localhost:8080 -t public) composer serve # http://localhost:8080 (php -S localhost:8080 -t public)
``` ```
Any web server can serve the app as long as the document root is `public/` and Any web server can serve the API as long as the document root is `public/` and
unknown paths fall through to `public/index.php`. unknown paths fall through to `public/index.php`. `public/.htaccess` also serves
a built frontend from `public/` (copy `web/dist/` there) and only falls back to
`index.php` for `/api` and when no `index.html` is present.
## Frontend ## Frontend
The Vue/TypeScript PWA lives in [web/](web/) and talks to this API. With the API The Vue/TypeScript PWA lives in [web/](web/). The production build is compiled
running (`docker compose up -d`): into the Docker image and served from the `app` container at `/`. For frontend
development, run the Vite dev server on the host — with the API container running
(`docker compose up -d`):
```bash ```bash
cd web cd web
@@ -87,12 +95,6 @@ npm install
npm run dev # http://localhost:5173, proxies /api to localhost:8080 npm run dev # http://localhost:5173, proxies /api to localhost:8080
``` ```
Or run it inside Compose alongside the API:
```bash
docker compose --profile frontend up -d
```
Unauthenticated visitors are redirected to `/login`; `/register` creates an Unauthenticated visitors are redirected to `/login`; `/register` creates an
account and signs in immediately. See [web/README.md](web/README.md). account and signs in immediately. See [web/README.md](web/README.md).
@@ -107,7 +109,7 @@ environment). See [.env.example](.env.example).
| `DATABASE_PATH` | `storage/database.sqlite` | SQLite file location | | `DATABASE_PATH` | `storage/database.sqlite` | SQLite file location |
| `JWT_SECRET` | auto-generated into `storage/secret.key` | Token signing key | | `JWT_SECRET` | auto-generated into `storage/secret.key` | Token signing key |
| `JWT_TTL` | `86400` | Token lifetime in seconds | | `JWT_TTL` | `86400` | Token lifetime in seconds |
| `APP_URL` | `http://localhost:5173` | Frontend base URL used to build magic links | | `APP_URL` | `http://localhost:8080` | Base URL used to build magic links (`http://localhost:5173` for a host `npm run dev`) |
| `MAIL_TRANSPORT` | `mail` | `mail` (PHP `mail()`), `smtp`, or `log` (append to a file) | | `MAIL_TRANSPORT` | `mail` | `mail` (PHP `mail()`), `smtp`, or `log` (append to a file) |
| `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@todo.test` / `Projects` | Envelope sender | | `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@todo.test` / `Projects` | Envelope sender |
| `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes | | `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes |
@@ -276,6 +278,9 @@ Project representation:
`GET /api/projects` returns `{ "projects": [ … ] }`. `GET /api/projects` returns `{ "projects": [ … ] }`.
Creating a project also seeds it with three **statuses** — "To do", "Doing",
"Done" (see [Statuses](#statuses)).
### Cards ### Cards
Scoped to a project; the parent project's ownership is checked first Scoped to a project; the parent project's ownership is checked first
@@ -283,23 +288,36 @@ Scoped to a project; the parent project's ownership is checked first
| Method | Path | Purpose | | Method | Path | Purpose |
|--------|------|---------| |--------|------|---------|
| `GET` | `/api/projects/{id}/cards` | cards, ordered by `position` then `id` | | `GET` | `/api/projects/{id}/cards` | every card, grouped by column (inbox first) then `position` |
| `POST` | `/api/projects/{id}/cards` | add a card | | `POST` | `/api/projects/{id}/cards` | add a card |
| `PUT` | `/api/projects/{id}/cards/order` | reorder all cards in one shot | | `PUT` | `/api/projects/{id}/cards/order` | set the order/contents of one status column |
| `GET` | `/api/projects/{id}/cards/{cardId}` | one card | | `GET` | `/api/projects/{id}/cards/{cardId}` | one card |
| `PATCH` | `/api/projects/{id}/cards/{cardId}` | update `text`, `complete`, and/or `position` | | `PATCH` | `/api/projects/{id}/cards/{cardId}` | update `text`, `complete`, and/or `status_id` |
| `DELETE` | `/api/projects/{id}/cards/{cardId}` | delete the card (`204`) | | `DELETE` | `/api/projects/{id}/cards/{cardId}` | delete the card (`204`) |
Create body: `text` (required, 11000 chars), `complete` (optional bool, Create body: `text` (required, 11000 chars), `complete` (optional bool,
default `false`), `position` (optional integer ≥ 0; when omitted the card is default `false`). `PATCH` needs at least one field.
appended after the current highest position). `PATCH` needs at least one field.
`position` is a plain sort key the client manages — updating one card never
renumbers its siblings.
`PUT …/cards/order` takes `{ "card_ids": [3, 1, 2] }` — every card in the **Ordering.** `position` is a dense `0..n-1` rank *within a column* — the cards
project, each exactly once (`422` otherwise). It rewrites positions to `0..n-1` that share a `(project_id, status_id)`. The inbox (`status_id IS NULL`) is its
in one transaction and returns `{ "cards": [ … ] }` in the new order. This is own column. New cards go to the end of the inbox. There is no project-wide order.
what the drag-and-drop reorder in the UI calls.
A new card has **no** status (`status_id: null`) — it sits in the project
"inbox" until the user gives it one. Two ways to move it:
- `PATCH …/cards/{cardId}` with `status_id` (a status id in this project, or
`null` for the inbox) — appends the card to the end of the destination column
and re-packs the one it left. `422` for an unknown or foreign status.
- `PUT …/cards/order` with `{ "status_id": <id|null>, "card_ids": [3, 1, 2] }`
makes those cards the exact contents of that column, in that order (positions
rewritten to `0..n-1`). Any card dragged in from another column is re-parented
and its old column re-packed, all in one transaction. `card_ids` must be
distinct cards of this project and must include every card already in the
target column (`422` otherwise). Returns `{ "cards": [ … ] }` for the whole
project. This is what the kanban board calls on every drop.
A status row that is deleted clears itself from its cards rather than deleting
them.
Card representation: Card representation:
@@ -311,13 +329,40 @@ Card representation:
"text": "Design homepage", "text": "Design homepage",
"complete": false, "complete": false,
"position": 0, "position": 0,
"status_id": null,
"status": null,
"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"
} }
} }
``` ```
`GET …/cards` returns `{ "cards": [ … ] }`. `status` is the embedded `{ id, name }` of the linked status, or `null` when the
card has none. `GET …/cards` returns `{ "cards": [ … ] }`.
### Statuses
Every project has an ordered set of card statuses, created with the project:
"To do", "Doing", "Done". They are project-specific — each project owns its own
rows. There is no create/update/delete for the statuses themselves yet; a card
is moved between them (or to the inbox) via `PATCH …/cards/{cardId}`.
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/projects/{id}/statuses` | the project's statuses, ordered by `position` |
```json
{
"statuses": [
{ "id": 1, "project_id": 1, "name": "To do", "position": 0 },
{ "id": 2, "project_id": 1, "name": "Doing", "position": 1 },
{ "id": 3, "project_id": 1, "name": "Done", "position": 2 }
]
}
```
Requires `Authorization: Bearer <jwt>`; a project that is missing or not owned by
the caller responds `404`.
### Error shape ### Error shape
@@ -349,6 +394,8 @@ PROJECT=$(curl -s -X POST $BASE/api/projects \
-d '{"title":"Website relaunch","description":"Q3"}' \ -d '{"title":"Website relaunch","description":"Q3"}' \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
curl -s $BASE/api/projects/$PROJECT/statuses -H "Authorization: Bearer $TOKEN"
curl -s -X POST $BASE/api/projects/$PROJECT/cards \ curl -s -X POST $BASE/api/projects/$PROJECT/cards \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"text":"Design homepage"}' -d '{"text":"Design homepage"}'
@@ -375,14 +422,14 @@ src/Auth/AuthMiddleware.php Bearer-token authentication
src/Auth/SessionPayload.php Shared user + session JSON shape src/Auth/SessionPayload.php Shared user + session JSON shape
src/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier src/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier
src/Http/JsonErrorHandler.php Uniform JSON error envelope src/Http/JsonErrorHandler.php Uniform JSON error envelope
src/Http/Controllers/ Request handlers (Auth, EmailVerification, Project, Card) src/Http/Controllers/ Request handlers (Auth, EmailVerification, Project, Card, CardStatus)
src/Repository/ Database access (User, EmailVerification, Project, Card) src/Repository/ Database access (User, EmailVerification, Project, Card, CardStatus)
src/Support/Validator.php Request-body validation helper src/Support/Validator.php Request-body validation helper
migrations/*.sql Schema, applied by bin/migrate.php migrations/*.sql Schema, applied by bin/migrate.php
Dockerfile PHP 8.3 + Apache image Dockerfile Multi-stage: Node frontend build + PHP 8.3/Apache runtime
docker-compose.yml Local stack: API + Mailpit; web via --profile frontend docker-compose.yml Local stack: app (SPA + API) + Mailpit
docker/ Apache vhost + container entrypoint docker/ Apache vhost + container entrypoint
web/ Vue 3 + TypeScript + Vite PWA frontend web/ Vue 3 + TypeScript + Vite PWA frontend (dev on the host)
``` ```
## Provenance ## Provenance
+32
View File
@@ -0,0 +1,32 @@
-- Project-specific card statuses. Every project gets a "To do" / "Doing" /
-- "Done" set when it is created (see CardStatusRepository::seedDefaults); the
-- backfill below covers projects that already existed.
CREATE TABLE IF NOT EXISTS card_statuses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
name TEXT NOT NULL,
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'))
);
CREATE INDEX IF NOT EXISTS idx_card_statuses_project_position
ON card_statuses (project_id, position);
-- Nullable, and left NULL for existing cards: a card without a status sits in
-- the project "inbox" until the user gives it one. Losing a status (a deleted
-- status row) clears the link rather than removing the card.
ALTER TABLE cards
ADD COLUMN status_id INTEGER REFERENCES card_statuses (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_cards_status ON cards (status_id);
-- Backfill: give every pre-existing project the default status set.
INSERT INTO card_statuses (project_id, name, position)
SELECT p.id, d.name, d.position
FROM projects p
CROSS JOIN (
SELECT 'To do' AS name, 0 AS position
UNION ALL SELECT 'Doing', 1
UNION ALL SELECT 'Done', 2
) d;
@@ -0,0 +1,24 @@
-- `cards.position` was a project-wide sort key. It is now a rank *within a
-- column* — the group of cards sharing a (project_id, status_id). The inbox
-- (status_id IS NULL) is its own column. The "all tasks" view no longer uses
-- position at all (it sorts alphabetically); ordering is a per-column concern
-- driven by PUT /api/projects/{id}/cards/order.
DROP INDEX IF EXISTS idx_cards_project_position;
CREATE INDEX IF NOT EXISTS idx_cards_project_status_position
ON cards (project_id, status_id, position);
-- Re-pack every existing column to a dense 0..n-1, preserving current order.
-- The CTE is evaluated against the pre-update snapshot, so this is safe despite
-- writing the same table.
WITH ranked (id, rk) AS (
SELECT id,
row_number() OVER (
PARTITION BY project_id, status_id
ORDER BY position, id
) - 1
FROM cards
)
UPDATE cards
SET position = (SELECT rk FROM ranked WHERE ranked.id = cards.id);
+43 -17
View File
@@ -6,6 +6,7 @@ namespace App\Http\Controllers;
use App\Exception\ApiException; use App\Exception\ApiException;
use App\Repository\CardRepository; use App\Repository\CardRepository;
use App\Repository\CardStatusRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Support\Validator; use App\Support\Validator;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
@@ -22,6 +23,7 @@ final class CardController extends Controller
public function __construct( public function __construct(
private readonly ProjectRepository $projects, private readonly ProjectRepository $projects,
private readonly CardRepository $cards, private readonly CardRepository $cards,
private readonly CardStatusRepository $statuses,
) { ) {
} }
@@ -82,11 +84,17 @@ 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('position')) { if ($validator->has('status_id')) {
$fields['position'] = $validator->optionalInt('position', 0); $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, position.'); $validator->add('text', 'Provide at least one of: text, complete, status_id.');
} }
$validator->assert(); $validator->assert();
@@ -111,29 +119,45 @@ final class CardController extends Controller
/** /**
* PUT /api/projects/{projectId}/cards/order * PUT /api/projects/{projectId}/cards/order
* *
* Body: { "card_ids": [3, 1, 2] } — every card in the project, exactly once, * Sets the contents and order of one status column.
* in the desired order. Positions are rewritten to 0..n-1. *
* Body: { "status_id": 2 | null, "card_ids": [3, 1, 2] } — the cards that
* should make up that column, in order. Positions are rewritten to 0..n-1;
* any card moved in from another column is re-parented and its old column
* re-packed. `status_id` is omitted or null for the inbox.
*/ */
public function reorder(Request $request, Response $response, array $args): Response public function reorder(Request $request, Response $response, array $args): Response
{ {
$projectId = $this->requireOwnedProjectId($request, $args); $projectId = $this->requireOwnedProjectId($request, $args);
$body = $this->body($request);
$order = $this->body($request)['card_ids'] ?? null; $statusId = null;
if (array_key_exists('status_id', $body) && $body['status_id'] !== null) {
if (!is_int($body['status_id'])) {
throw new ApiException('status_id must be a status ID or null.', 422);
}
$statusId = $body['status_id'];
if ($this->statuses->findInProject($statusId, $projectId) === null) {
throw new ApiException('That status does not belong to this project.', 422);
}
}
$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)) !== []) {
throw new ApiException('card_ids must be an array of card IDs.', 422); throw new ApiException('card_ids must be an array of card IDs.', 422);
} }
/** @var int[] $order */ /** @var int[] $order */
$expected = $this->cards->idsForProject($projectId); if (count($order) !== count(array_unique($order))) {
$given = $order; throw new ApiException('card_ids must not contain duplicates.', 422);
sort($given); }
sort($expected); if (array_diff($order, $this->cards->idsForProject($projectId)) !== []) {
throw new ApiException('Every card_id must be a card in this project.', 422);
if ($given !== $expected) { }
throw new ApiException('card_ids must contain every card in the project exactly once.', 422); if (array_diff($this->cards->idsInColumn($projectId, $statusId), $order) !== []) {
throw new ApiException('card_ids must include every card already in this column.', 422);
} }
$cards = $this->cards->reorder($projectId, $order); $cards = $this->cards->orderColumn($projectId, $statusId, $order);
$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)]);
@@ -155,7 +179,7 @@ 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, created_at: string, updated_at: string} * @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}
*/ */
private function requireCard(int $projectId, array $args): array private function requireCard(int $projectId, array $args): array
{ {
@@ -169,7 +193,7 @@ final class CardController extends Controller
} }
/** /**
* @param array{id: int, project_id: int, text: string, complete: bool, position: int, created_at: string, updated_at: string} $card * @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
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function present(array $card): array private function present(array $card): array
@@ -180,6 +204,8 @@ final class CardController extends Controller
'text' => $card['text'], 'text' => $card['text'],
'complete' => $card['complete'], 'complete' => $card['complete'],
'position' => $card['position'], 'position' => $card['position'],
'status_id' => $card['status_id'],
'status' => $card['status'],
'created_at' => $card['created_at'], 'created_at' => $card['created_at'],
'updated_at' => $card['updated_at'], 'updated_at' => $card['updated_at'],
]; ];
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Exception\ApiException;
use App\Repository\CardStatusRepository;
use App\Repository\ProjectRepository;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Read-only listing of a project's card statuses. Statuses are seeded when the
* project is created; there is no create/update/delete yet.
*/
final class CardStatusController extends Controller
{
public function __construct(
private readonly ProjectRepository $projects,
private readonly CardStatusRepository $statuses,
) {
}
/**
* GET /api/projects/{projectId}/statuses
*/
public function index(Request $request, Response $response, array $args): Response
{
$projectId = $this->requireOwnedProjectId($request, $args);
return $this->json($response, [
'statuses' => array_map($this->present(...), $this->statuses->allForProject($projectId)),
]);
}
/**
* @param array<string, string> $args
*/
private function requireOwnedProjectId(Request $request, array $args): int
{
$project = $this->projects->findOwnedBy((int) $args['projectId'], $this->user($request)['id']);
if ($project === null) {
throw new ApiException('Project not found.', 404);
}
return $project['id'];
}
/**
* @param array{id: int, project_id: int, name: string, position: int, created_at: string, updated_at: string} $status
* @return array<string, mixed>
*/
private function present(array $status): array
{
return [
'id' => $status['id'],
'project_id' => $status['project_id'],
'name' => $status['name'],
'position' => $status['position'],
];
}
}
+6 -2
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Exception\ApiException; use App\Exception\ApiException;
use App\Repository\CardStatusRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Support\Validator; use App\Support\Validator;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
@@ -20,8 +21,10 @@ final class ProjectController extends Controller
private const DESCRIPTION_MAX = 2000; private const DESCRIPTION_MAX = 2000;
private const MAX_PROJECTS_PER_OWNER = 100; private const MAX_PROJECTS_PER_OWNER = 100;
public function __construct(private readonly ProjectRepository $projects) public function __construct(
{ private readonly ProjectRepository $projects,
private readonly CardStatusRepository $statuses,
) {
} }
/** /**
@@ -54,6 +57,7 @@ final class ProjectController extends Controller
} }
$project = $this->projects->create($ownerId, $title, $description); $project = $this->projects->create($ownerId, $title, $description);
$this->statuses->seedDefaults($project['id']);
return $this->json($response, ['project' => $this->present($project)], 201); return $this->json($response, ['project' => $this->present($project)], 201);
} }
+169 -28
View File
@@ -9,24 +9,41 @@ 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
* (project_id, status_id). The inbox is the column where status_id IS NULL.
*
* @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, project_id: int, text: string, complete: bool, position: int,
* status_id: int|null, status: CardStatus|null,
* created_at: string, updated_at: string * created_at: string, updated_at: string
* } * }
*/ */
final class CardRepository final class CardRepository
{ {
private const SELECT = <<<'SQL'
SELECT c.id, c.project_id, c.text, c.complete, c.position, c.status_id,
c.created_at, c.updated_at,
s.name AS status_name
FROM cards c
LEFT JOIN card_statuses s ON s.id = c.status_id
SQL;
public function __construct(private readonly PDO $pdo) public function __construct(private readonly PDO $pdo)
{ {
} }
/** /**
* 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
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
'SELECT * FROM cards WHERE project_id = :project ORDER BY position ASC, id ASC' self::SELECT . ' WHERE c.project_id = :project ORDER BY c.status_id, c.position ASC, c.id ASC'
); );
$stmt->execute(['project' => $projectId]); $stmt->execute(['project' => $projectId]);
@@ -38,7 +55,7 @@ final class CardRepository
*/ */
public function findInProject(int $id, int $projectId): ?array public function findInProject(int $id, int $projectId): ?array
{ {
$stmt = $this->pdo->prepare('SELECT * FROM cards WHERE id = :id AND project_id = :project'); $stmt = $this->pdo->prepare(self::SELECT . ' WHERE c.id = :id AND c.project_id = :project');
$stmt->execute(['id' => $id, 'project' => $projectId]); $stmt->execute(['id' => $id, 'project' => $projectId]);
$row = $stmt->fetch(); $row = $stmt->fetch();
@@ -51,7 +68,8 @@ final class CardRepository
*/ */
public function create(int $projectId, string $text, bool $complete, ?int $position): array public function create(int $projectId, string $text, bool $complete, ?int $position): array
{ {
$position ??= $this->nextPosition($projectId); // A new card has no status — it goes to the end of the inbox column.
$position ??= $this->nextPositionInColumn($projectId, null);
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
'INSERT INTO cards (project_id, text, complete, position) 'INSERT INTO cards (project_id, text, complete, position)
@@ -71,12 +89,22 @@ final class CardRepository
} }
/** /**
* @param array{text?: string, complete?: bool, position?: int} $fields * Update simple fields, and/or move the card to another column. A status
* change drops the card at the end of the destination column and re-packs
* the one it left. Precise slotting within a column is done via orderColumn().
*
* @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 update(int $id, int $projectId, array $fields): array
{ {
$sets = ['updated_at = ' . "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"]; /** @var CardRow $card */
$card = $this->findInProject($id, $projectId);
$movesColumn = array_key_exists('status_id', $fields) && $fields['status_id'] !== $card['status_id'];
$sourceColumn = $card['status_id'];
$sets = ['updated_at = ' . $this->nowExpr()];
$params = ['id' => $id]; $params = ['id' => $id];
if (array_key_exists('text', $fields)) { if (array_key_exists('text', $fields)) {
@@ -87,18 +115,40 @@ 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('position', $fields)) { if (array_key_exists('status_id', $fields)) {
$sets[] = 'status_id = :status_id';
$params['status_id'] = $fields['status_id'];
}
if ($movesColumn) {
$sets[] = 'position = :position'; $sets[] = 'position = :position';
$params['position'] = $fields['position']; $params['position'] = $this->nextPositionInColumn($projectId, $fields['status_id']);
} }
$stmt = $this->pdo->prepare('UPDATE cards SET ' . implode(', ', $sets) . ' WHERE id = :id'); $sql = 'UPDATE cards SET ' . implode(', ', $sets) . ' WHERE id = :id';
$stmt->execute($params);
/** @var CardRow $card */ if (!$movesColumn) {
$card = $this->findInProject($id, $projectId); $this->pdo->prepare($sql)->execute($params);
return $card; /** @var CardRow $updated */
$updated = $this->findInProject($id, $projectId);
return $updated;
}
$this->pdo->beginTransaction();
try {
$this->pdo->prepare($sql)->execute($params);
$this->repack($projectId, $sourceColumn);
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
/** @var CardRow $updated */
$updated = $this->findInProject($id, $projectId);
return $updated;
} }
public function delete(int $id): void public function delete(int $id): void
@@ -107,39 +157,69 @@ final class CardRepository
} }
/** /**
* IDs of every card in the project, in current position order. * IDs of every card in the project.
* *
* @return int[] * @return int[]
*/ */
public function idsForProject(int $projectId): array public function idsForProject(int $projectId): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare('SELECT id FROM cards WHERE project_id = :project');
'SELECT id FROM cards WHERE project_id = :project ORDER BY position ASC, id ASC'
);
$stmt->execute(['project' => $projectId]); $stmt->execute(['project' => $projectId]);
return array_map(intval(...), $stmt->fetchAll(PDO::FETCH_COLUMN)); return array_map(intval(...), $stmt->fetchAll(PDO::FETCH_COLUMN));
} }
/** /**
* Assign positions 0..n-1 to the given cards in one transaction. * IDs of the cards currently in one column, in position order.
* *
* @param int[] $orderedIds every card id in the project, exactly once * @return int[]
* @return CardRow[] the project's cards in their new order
*/ */
public function reorder(int $projectId, array $orderedIds): array public function idsInColumn(int $projectId, ?int $statusId): array
{ {
[$match, $params] = $this->columnMatch($statusId);
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
'UPDATE cards SET position = :position, "SELECT id FROM cards WHERE project_id = :project AND {$match} ORDER BY position ASC, id ASC"
updated_at = ' . "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')" . '
WHERE id = :id AND project_id = :project'
); );
$stmt->execute(['project' => $projectId] + $params);
return array_map(intval(...), $stmt->fetchAll(PDO::FETCH_COLUMN));
}
/**
* Set the contents and order of one column. Every id in $orderedIds is moved
* into $statusId at positions 0..n-1; any other column those cards came from
* is re-packed. One transaction.
*
* @param int[] $orderedIds
* @return CardRow[] the project's cards, grouped by column
*/
public function orderColumn(int $projectId, ?int $statusId, array $orderedIds): array
{
$orderedIds = array_values($orderedIds);
$this->pdo->beginTransaction(); $this->pdo->beginTransaction();
try { try {
foreach (array_values($orderedIds) as $position => $id) { $sourceColumns = $this->columnsOf($projectId, $orderedIds);
$stmt->execute(['position' => $position, 'id' => $id, 'project' => $projectId]);
$place = $this->pdo->prepare(
'UPDATE cards SET status_id = :status, position = :position, updated_at = ' . $this->nowExpr() . '
WHERE id = :id AND project_id = :project'
);
foreach ($orderedIds as $position => $id) {
$place->execute([
'status' => $statusId,
'position' => $position,
'id' => $id,
'project' => $projectId,
]);
} }
foreach ($sourceColumns as $source) {
if ($source !== $statusId) {
$this->repack($projectId, $source);
}
}
$this->pdo->commit(); $this->pdo->commit();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->pdo->rollBack(); $this->pdo->rollBack();
@@ -149,16 +229,70 @@ final class CardRepository
return $this->allForProject($projectId); return $this->allForProject($projectId);
} }
private function nextPosition(int $projectId): int /**
* The distinct status_id values (columns) the given cards currently sit in.
*
* @param int[] $ids
* @return array<int, int|null>
*/
private function columnsOf(int $projectId, array $ids): array
{ {
if ($ids === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
'SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE project_id = :project' "SELECT DISTINCT status_id FROM cards WHERE project_id = ? AND id IN ($placeholders)"
); );
$stmt->execute(['project' => $projectId]); $stmt->execute([$projectId, ...$ids]);
return array_map(
static fn ($value) => $value === null ? null : (int) $value,
$stmt->fetchAll(PDO::FETCH_COLUMN),
);
}
/** Rewrite one column's positions to a dense 0..n-1 in current order. */
private function repack(int $projectId, ?int $statusId): void
{
$ids = $this->idsInColumn($projectId, $statusId);
$update = $this->pdo->prepare('UPDATE cards SET position = :position WHERE id = :id');
foreach ($ids as $position => $id) {
$update->execute(['position' => $position, 'id' => $id]);
}
}
private function nextPositionInColumn(int $projectId, ?int $statusId): int
{
[$match, $params] = $this->columnMatch($statusId);
$stmt = $this->pdo->prepare(
"SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE project_id = :project AND {$match}"
);
$stmt->execute(['project' => $projectId] + $params);
return (int) $stmt->fetchColumn(); return (int) $stmt->fetchColumn();
} }
/**
* A WHERE fragment matching one column, since SQLite needs `IS NULL` (not
* `= NULL`) for the inbox.
*
* @return array{0: string, 1: array<string, int>}
*/
private function columnMatch(?int $statusId): array
{
return $statusId === null
? ['status_id IS NULL', []]
: ['status_id = :status', ['status' => $statusId]];
}
private function nowExpr(): string
{
return "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')";
}
/** /**
* @param array<string, mixed> $row * @param array<string, mixed> $row
* @return CardRow * @return CardRow
@@ -170,6 +304,13 @@ final class CardRepository
$row['complete'] = (bool) $row['complete']; $row['complete'] = (bool) $row['complete'];
$row['position'] = (int) $row['position']; $row['position'] = (int) $row['position'];
$statusId = $row['status_id'] === null ? null : (int) $row['status_id'];
$row['status_id'] = $statusId;
$row['status'] = $statusId === null
? null
: ['id' => $statusId, 'name' => (string) $row['status_name']];
unset($row['status_name']);
/** @var CardRow $row */ /** @var CardRow $row */
return $row; return $row;
} }
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use PDO;
/**
* Data access for the `card_statuses` table. Statuses belong to a single
* project. There is no CRUD yet — projects are seeded with a fixed set on
* creation and that is the only way rows appear.
*
* @phpstan-type CardStatusRow array{
* id: int, project_id: int, name: string, position: int,
* created_at: string, updated_at: string
* }
*/
final class CardStatusRepository
{
/** The status set created for every new project, in order. */
public const DEFAULTS = ['To do', 'Doing', 'Done'];
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return CardStatusRow[]
*/
public function allForProject(int $projectId): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM card_statuses WHERE project_id = :project ORDER BY position ASC, id ASC'
);
$stmt->execute(['project' => $projectId]);
return array_map($this->cast(...), $stmt->fetchAll());
}
/**
* @return CardStatusRow|null
*/
public function findInProject(int $id, int $projectId): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM card_statuses WHERE id = :id AND project_id = :project');
$stmt->execute(['id' => $id, 'project' => $projectId]);
$row = $stmt->fetch();
return $row === false ? null : $this->cast($row);
}
/** Insert the default status set for a freshly created project. */
public function seedDefaults(int $projectId): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO card_statuses (project_id, name, position) VALUES (:project, :name, :position)'
);
foreach (array_values(self::DEFAULTS) as $position => $name) {
$stmt->execute(['project' => $projectId, 'name' => $name, 'position' => $position]);
}
}
/**
* @param array<string, mixed> $row
* @return CardStatusRow
*/
private function cast(array $row): array
{
$row['id'] = (int) $row['id'];
$row['project_id'] = (int) $row['project_id'];
$row['position'] = (int) $row['position'];
/** @var CardStatusRow $row */
return $row;
}
}
+21
View File
@@ -90,6 +90,27 @@ 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;
+14 -3
View File
@@ -7,6 +7,7 @@ use App\Auth\JwtService;
use App\Auth\SessionPayload; use App\Auth\SessionPayload;
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\CardController; use App\Http\Controllers\CardController;
use App\Http\Controllers\CardStatusController;
use App\Http\Controllers\EmailVerificationController; use App\Http\Controllers\EmailVerificationController;
use App\Http\Controllers\ProjectController; use App\Http\Controllers\ProjectController;
use App\Http\JsonErrorHandler; use App\Http\JsonErrorHandler;
@@ -15,6 +16,7 @@ use App\Mail\LogMailer;
use App\Mail\Mailer; use App\Mail\Mailer;
use App\Mail\PhpMailerMailer; use App\Mail\PhpMailerMailer;
use App\Repository\CardRepository; use App\Repository\CardRepository;
use App\Repository\CardStatusRepository;
use App\Repository\EmailVerificationRepository; use App\Repository\EmailVerificationRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
@@ -43,6 +45,7 @@ $errorMiddleware->setDefaultErrorHandler(
$users = new UserRepository($database->pdo()); $users = new UserRepository($database->pdo());
$projects = new ProjectRepository($database->pdo()); $projects = new ProjectRepository($database->pdo());
$cards = new CardRepository($database->pdo()); $cards = new CardRepository($database->pdo());
$cardStatuses = new CardStatusRepository($database->pdo());
$verificationTokens = new EmailVerificationRepository($database->pdo()); $verificationTokens = new EmailVerificationRepository($database->pdo());
$jwt = new JwtService($config->jwtSecret, $config->jwtTtl); $jwt = new JwtService($config->jwtSecret, $config->jwtTtl);
$session = new SessionPayload($jwt, $verificationTokens); $session = new SessionPayload($jwt, $verificationTokens);
@@ -55,8 +58,9 @@ $verifier = new EmailVerifier($verificationTokens, $users, $mailer, $config->app
$authController = new AuthController($users, $session, $verifier); $authController = new AuthController($users, $session, $verifier);
$emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session); $emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session);
$projectController = new ProjectController($projects); $projectController = new ProjectController($projects, $cardStatuses);
$cardController = new CardController($projects, $cards); $cardController = new CardController($projects, $cards, $cardStatuses);
$cardStatusController = new CardStatusController($projects, $cardStatuses);
$authMiddleware = new AuthMiddleware($jwt, $users); $authMiddleware = new AuthMiddleware($jwt, $users);
// --- Routes --------------------------------------------------------------- // --- Routes ---------------------------------------------------------------
@@ -66,6 +70,7 @@ $app->group('/api', function (RouteCollectorProxy $group) use (
$emailController, $emailController,
$projectController, $projectController,
$cardController, $cardController,
$cardStatusController,
$authMiddleware, $authMiddleware,
) { ) {
$group->get('/health', function (Request $request, Response $response): Response { $group->get('/health', function (Request $request, Response $response): Response {
@@ -82,13 +87,19 @@ $app->group('/api', function (RouteCollectorProxy $group) use (
$group->post('/email/verification', [$emailController, 'resend'])->add($authMiddleware); $group->post('/email/verification', [$emailController, 'resend'])->add($authMiddleware);
$group->post('/email/change', [$emailController, 'requestChange'])->add($authMiddleware); $group->post('/email/change', [$emailController, 'requestChange'])->add($authMiddleware);
$group->group('/projects', function (RouteCollectorProxy $projects) use ($projectController, $cardController) { $group->group('/projects', function (RouteCollectorProxy $projects) use (
$projectController,
$cardController,
$cardStatusController,
) {
$projects->get('', [$projectController, 'index']); $projects->get('', [$projectController, 'index']);
$projects->post('', [$projectController, 'store']); $projects->post('', [$projectController, 'store']);
$projects->get('/{projectId:[0-9]+}', [$projectController, 'show']); $projects->get('/{projectId:[0-9]+}', [$projectController, 'show']);
$projects->patch('/{projectId:[0-9]+}', [$projectController, 'update']); $projects->patch('/{projectId:[0-9]+}', [$projectController, 'update']);
$projects->delete('/{projectId:[0-9]+}', [$projectController, 'destroy']); $projects->delete('/{projectId:[0-9]+}', [$projectController, 'destroy']);
$projects->get('/{projectId:[0-9]+}/statuses', [$cardStatusController, 'index']);
$projects->get('/{projectId:[0-9]+}/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']); $projects->put('/{projectId:[0-9]+}/cards/order', [$cardController, 'reorder']);
+256
View File
@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace Tests;
/**
* `position` is a dense rank within a column — the cards sharing a
* (project, status). PUT /api/projects/{id}/cards/order sets one column's
* contents and order.
*/
final class CardOrderTest extends ApiTestCase
{
/** @param array<string, string> $auth */
private function newProject(array $auth, string $title = 'Board'): int
{
return $this->decode($this->request('POST', '/api/projects', ['title' => $title], $auth))['project']['id'];
}
/** @param array<string, string> $auth @return array<int, mixed> */
private function statuses(int $projectId, array $auth): array
{
return $this->decode(
$this->request('GET', "/api/projects/{$projectId}/statuses", null, $auth),
)['statuses'];
}
/** @param array<string, string> $auth */
private function addCard(int $projectId, string $text, array $auth): int
{
return $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => $text], $auth),
)['card']['id'];
}
/** @param array<string, string> $auth @return list<array{text: string, status_id: int|null, position: int}> */
private function cards(int $projectId, array $auth): array
{
return array_map(
static fn (array $c): array => [
'text' => $c['text'],
'status_id' => $c['status_id'],
'position' => $c['position'],
],
$this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'],
);
}
public function test_new_cards_are_ranked_densely_within_the_inbox(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
foreach (['A', 'B', 'C'] as $text) {
$this->addCard($projectId, $text, $auth);
}
self::assertSame(
[['text' => 'A', 'status_id' => null, 'position' => 0],
['text' => 'B', 'status_id' => null, 'position' => 1],
['text' => 'C', 'status_id' => null, 'position' => 2]],
$this->cards($projectId, $auth),
);
}
public function test_reorder_within_the_inbox_column(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$ids = [];
foreach (['A', 'B', 'C'] as $text) {
$ids[$text] = $this->addCard($projectId, $text, $auth);
}
$response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => null,
'card_ids' => [$ids['C'], $ids['A'], $ids['B']],
], $auth);
self::assertSame(200, $response->getStatusCode());
self::assertSame(['C', 'A', 'B'], array_column($this->decode($response)['cards'], 'text'));
self::assertSame([0, 1, 2], array_column($this->cards($projectId, $auth), 'position'));
}
public function test_ordering_a_status_column_moves_cards_into_it_and_repacks_the_inbox(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$ids = [];
foreach (['A', 'B', 'C'] as $text) {
$ids[$text] = $this->addCard($projectId, $text, $auth);
}
// Move B and C into "To do" (C first), leaving A alone in the inbox.
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$ids['C'], $ids['B']],
], $auth);
$byText = [];
foreach ($this->cards($projectId, $auth) as $c) {
$byText[$c['text']] = $c;
}
self::assertSame(['status_id' => null, 'position' => 0], ['status_id' => $byText['A']['status_id'], 'position' => $byText['A']['position']]);
self::assertSame(['status_id' => $todo, 'position' => 0], ['status_id' => $byText['C']['status_id'], 'position' => $byText['C']['position']]);
self::assertSame(['status_id' => $todo, 'position' => 1], ['status_id' => $byText['B']['status_id'], 'position' => $byText['B']['position']]);
}
public function test_moving_a_card_out_of_the_inbox_repacks_the_survivors(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$ids = [];
foreach (['A', 'B', 'C'] as $text) {
$ids[$text] = $this->addCard($projectId, $text, $auth); // inbox 0,1,2
}
// Pull the middle card into "To do".
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$ids['B']],
], $auth);
$inbox = array_values(array_filter($this->cards($projectId, $auth), static fn ($c) => $c['status_id'] === null));
self::assertSame(
[['text' => 'A', 'status_id' => null, 'position' => 0],
['text' => 'C', 'status_id' => null, 'position' => 1]],
$inbox,
);
}
public function test_a_new_inbox_card_lands_after_the_repacked_survivors(): 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); // inbox: A@0, B@1
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$a],
], $auth); // inbox now: B@0
$newId = $this->addCard($projectId, 'C', $auth);
$byId = [];
foreach ($this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'] as $c) {
$byId[$c['id']] = $c;
}
self::assertSame(0, $byId[$b]['position']);
self::assertSame(1, $byId[$newId]['position']);
self::assertNull($byId[$newId]['status_id']);
}
public function test_patch_status_appends_the_card_to_the_destination_column(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$first = $this->addCard($projectId, 'first', $auth);
$second = $this->addCard($projectId, 'second', $auth);
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$first}", ['status_id' => $todo], $auth);
$moved = $this->decode(
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$second}", ['status_id' => $todo], $auth),
)['card'];
self::assertSame($todo, $moved['status_id']);
self::assertSame(1, $moved['position']); // after `first`, which took slot 0
}
public function test_reorder_rejects_a_foreign_status(): void
{
$auth = $this->authHeader();
$mine = $this->newProject($auth, 'Mine');
$other = $this->newProject($auth, 'Other');
$foreignStatus = $this->statuses($other, $auth)[0]['id'];
$card = $this->addCard($mine, 'x', $auth);
$response = $this->request('PUT', "/api/projects/{$mine}/cards/order", [
'status_id' => $foreignStatus,
'card_ids' => [$card],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_rejects_a_card_from_another_project(): void
{
$auth = $this->authHeader();
$mine = $this->newProject($auth, 'Mine');
$other = $this->newProject($auth, 'Other');
$foreignCard = $this->addCard($other, 'x', $auth);
$response = $this->request('PUT', "/api/projects/{$mine}/cards/order", [
'status_id' => null,
'card_ids' => [$foreignCard],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_must_list_every_card_already_in_the_target_column(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$a = $this->addCard($projectId, 'A', $auth);
$b = $this->addCard($projectId, 'B', $auth);
// Put both in "To do".
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$a, $b],
], $auth);
// Now try to reorder "To do" mentioning only one of them.
$response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$b],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_rejects_duplicate_ids(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$a = $this->addCard($projectId, 'A', $auth);
$response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => null,
'card_ids' => [$a, $a],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_is_scoped_to_the_owner(): void
{
$owner = $this->authHeader('owner@example.com');
$other = $this->authHeader('other@example.com');
$projectId = $this->newProject($owner);
$card = $this->addCard($projectId, 'x', $owner);
self::assertSame(404, $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => null,
'card_ids' => [$card],
], $other)->getStatusCode());
}
}
+170
View File
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Tests;
final class CardStatusTest extends ApiTestCase
{
/** Create a project and return its id. */
private function newProject(array $auth, string $title = 'Board'): int
{
return $this->decode(
$this->request('POST', '/api/projects', ['title' => $title], $auth),
)['project']['id'];
}
/** @param array<string, string> $auth @return array<int, mixed> */
private function statuses(int $projectId, array $auth): array
{
return $this->decode(
$this->request('GET', "/api/projects/{$projectId}/statuses", null, $auth),
)['statuses'];
}
public function test_statuses_require_authentication(): void
{
$projectId = $this->newProject($this->authHeader());
self::assertSame(401, $this->request('GET', "/api/projects/{$projectId}/statuses")->getStatusCode());
}
public function test_new_projects_are_seeded_with_the_default_statuses(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$statuses = $this->statuses($projectId, $auth);
self::assertSame(['To do', 'Doing', 'Done'], array_column($statuses, 'name'));
self::assertSame([0, 1, 2], array_column($statuses, 'position'));
self::assertSame([$projectId, $projectId, $projectId], array_column($statuses, 'project_id'));
}
public function test_each_project_gets_its_own_status_rows(): void
{
$auth = $this->authHeader();
$first = $this->newProject($auth, 'One');
$second = $this->newProject($auth, 'Two');
$firstIds = array_column($this->statuses($first, $auth), 'id');
$secondIds = array_column($this->statuses($second, $auth), 'id');
self::assertSame([], array_intersect($firstIds, $secondIds));
}
public function test_statuses_are_only_visible_to_the_project_owner(): void
{
$owner = $this->authHeader('owner@example.com');
$other = $this->authHeader('other@example.com');
$projectId = $this->newProject($owner, 'Private');
self::assertSame(200, $this->request('GET', "/api/projects/{$projectId}/statuses", null, $owner)->getStatusCode());
self::assertSame(404, $this->request('GET', "/api/projects/{$projectId}/statuses", null, $other)->getStatusCode());
}
public function test_a_new_card_has_no_status(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$card = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'First'], $auth),
)['card'];
// New cards sit in the "inbox" — no status until the user assigns one.
self::assertArrayHasKey('status_id', $card);
self::assertNull($card['status_id']);
self::assertNull($card['status']);
}
public function test_a_card_can_be_moved_between_statuses_and_back_to_the_inbox(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$statuses = $this->statuses($projectId, $auth);
$cardId = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'Move me'], $auth),
)['card']['id'];
$doing = $this->decode(
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$cardId}", ['status_id' => $statuses[1]['id']], $auth),
)['card'];
self::assertSame($statuses[1]['id'], $doing['status_id']);
self::assertSame('Doing', $doing['status']['name']);
$backToInbox = $this->decode(
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$cardId}", ['status_id' => null], $auth),
)['card'];
self::assertNull($backToInbox['status_id']);
self::assertNull($backToInbox['status']);
}
public function test_a_card_rejects_a_status_from_another_project(): void
{
$auth = $this->authHeader();
$mine = $this->newProject($auth, 'Mine');
$other = $this->newProject($auth, 'Other');
$foreignStatusId = $this->statuses($other, $auth)[0]['id'];
$cardId = $this->decode(
$this->request('POST', "/api/projects/{$mine}/cards", ['text' => 'x'], $auth),
)['card']['id'];
$response = $this->request('PATCH', "/api/projects/{$mine}/cards/{$cardId}", ['status_id' => $foreignStatusId], $auth);
self::assertSame(422, $response->getStatusCode());
self::assertArrayHasKey('status_id', $this->decode($response)['error']['details']);
}
public function test_a_card_rejects_an_unknown_status(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$cardId = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $auth),
)['card']['id'];
$response = $this->request('PATCH', "/api/projects/{$projectId}/cards/{$cardId}", ['status_id' => 999999], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_deleting_a_status_clears_it_from_its_cards(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$cardId = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'Orphan me'], $auth),
)['card']['id'];
$statusId = $this->statuses($projectId, $auth)[0]['id'];
$this->request('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()->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
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth, 'Temp');
$count = fn (): int => (int) $this->db()
->query("SELECT COUNT(*) FROM card_statuses WHERE project_id = {$projectId}")
->fetchColumn();
self::assertSame(3, $count());
$this->request('DELETE', "/api/projects/{$projectId}", null, $auth);
self::assertSame(0, $count());
}
}
+39 -23
View File
@@ -2,7 +2,7 @@
Vue 3 + TypeScript + Vite PWA. Talks to the REST API in the parent directory. Vue 3 + TypeScript + Vite PWA. Talks to the REST API in the parent directory.
## Develop on the host ## Develop
```bash ```bash
npm install npm install
@@ -14,17 +14,6 @@ run `docker compose up -d` in the parent directory first). Override the target
with `VITE_PROXY_TARGET`, or point the app at a different API entirely with with `VITE_PROXY_TARGET`, or point the app at a different API entirely with
`VITE_API_BASE_URL` (see [.env.example](.env.example)). `VITE_API_BASE_URL` (see [.env.example](.env.example)).
## Develop in Docker
From the parent directory:
```bash
docker compose --profile frontend up -d
```
Runs this dev server alongside the API. `/api` is proxied to the `app` container.
After changing `package.json`, rebuild: `docker compose build web`.
## Build ## Build
```bash ```bash
@@ -32,6 +21,11 @@ npm run build # type-checks, then emits dist/
npm run preview npm run preview
``` ```
The parent `Dockerfile` runs this build in a Node stage and copies `dist/` into
the PHP image's `public/`, so the `app` container serves the compiled SPA at `/`.
There is no separate frontend container — a production image is `docker compose
build app` from the parent directory.
## Layout ## Layout
``` ```
@@ -39,25 +33,47 @@ 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, register/login/fetchMe src/stores/auth.ts Pinia store: token in localStorage, register/login/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 + drag reorder) src/stores/cards.ts Pinia store: one project's cards (CRUD + reorderColumn)
src/lib/api.ts fetch wrapper, bearer token, typed ApiError src/lib/api.ts fetch wrapper, bearer token, typed ApiError
src/components/CardRow.vue checkbox + editable text + 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/views/ HomeView, ProjectView, LoginView, RegisterView, src/views/ HomeView, ProjectView, LoginView, RegisterView,
ProfileView, VerifyEmailView ProfileView, VerifyEmailView
``` ```
## Project detail ## Project detail
`/projects/:id` shows one project. The title and description are inline-editable `/projects/:id` shows one project. It renders on a **full-width** layout (the
(saved on blur via `PATCH /api/projects/:id`; the description shows an "Add a route sets `meta.wide`, which widens `.app__main` in `App.vue`). The title and
description" placeholder when empty). A **Manage** menu (top right) has a description are inline-editable (saved on blur via `PATCH /api/projects/:id`; the
**Delete project** action that opens a confirmation modal; confirming calls description shows an "Add a description" placeholder when empty). A **Manage**
`DELETE /api/projects/:id` and returns to the all-projects view. menu (top right) has a **Delete project** action that opens a confirmation modal;
confirming calls `DELETE /api/projects/:id` and returns to the all-projects view.
Each card row is a checkbox, an inline-editable text field (saved on blur), a Below the header are two tabs (local `activeTab` state, `v-show` so both stay
delete button, and a drag handle. Reordering uses `vuedraggable`; on drop the mounted):
whole new order is persisted via `PUT /api/projects/:id/cards/order`, and the
server response replaces local state. ### All tasks
The flat card list, **sorted by name (case-insensitive)** via a `sortedCards`
computed — there is no manual order here. Each row is an inline-editable text
field (saved on blur), a status chip (`card.status.name` or "No status"), and a
delete button.
### Kanban
Columns, left to right: **Inbox** (cards with no status) then each project
status in `position` order. `board` is derived from `cards.cards` + the
project's statuses and rebuilt by a `watch` whenever either changes.
Every drop — whether reordering within a column (`moved`) or dragging in from
another (`added`) — calls `cards.reorderColumn(column.statusId, ids)`
`PUT /api/projects/:id/cards/order` with `{ status_id, card_ids }`. The server
re-parents any moved-in card, re-packs the source column, and returns the whole
project's cards, which replaces local state; on failure the board reloads.
The Inbox column has a small name + **Add** form at the bottom (`cards.add`);
new cards have no status, so they land straight in it.
## Auth flow ## Auth flow
+3 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterLink, RouterView, useRouter } from 'vue-router' import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
import { useCardsStore } from './stores/cards' import { useCardsStore } from './stores/cards'
import { useProjectsStore } from './stores/projects' import { useProjectsStore } from './stores/projects'
@@ -8,6 +8,7 @@ const auth = useAuthStore()
const projects = useProjectsStore() const projects = useProjectsStore()
const cards = useCardsStore() const cards = useCardsStore()
const router = useRouter() const router = useRouter()
const route = useRoute()
async function onLogout() { async function onLogout() {
auth.logout() auth.logout()
@@ -31,7 +32,7 @@ async function onLogout() {
</div> </div>
</header> </header>
<main class="app__main"> <main class="app__main" :class="{ 'app__main--wide': route.meta.wide }">
<RouterView /> <RouterView />
</main> </main>
</div> </div>
+5 -12
View File
@@ -4,7 +4,6 @@ import type { Card } from '../types'
const props = defineProps<{ card: Card }>() const props = defineProps<{ card: Card }>()
const emit = defineEmits<{ const emit = defineEmits<{
toggle: [complete: boolean]
'save-text': [text: string] 'save-text': [text: string]
delete: [] delete: []
}>() }>()
@@ -28,17 +27,7 @@ function commit() {
</script> </script>
<template> <template>
<li class="card-row" :class="{ 'card-row--done': card.complete }"> <li class="card-row">
<span class="card-row__handle" aria-hidden="true" title="Drag to reorder"></span>
<input
class="card-row__check"
type="checkbox"
:checked="card.complete"
:aria-label="card.complete ? 'Mark as not done' : 'Mark as done'"
@change="emit('toggle', ($event.target as HTMLInputElement).checked)"
/>
<input <input
v-model="text" v-model="text"
class="card-row__text" class="card-row__text"
@@ -49,6 +38,10 @@ function commit() {
@keyup.enter="($event.target as HTMLInputElement).blur()" @keyup.enter="($event.target as HTMLInputElement).blur()"
/> />
<span class="card-row__status" :class="{ 'card-row__status--none': !card.status }">
{{ card.status?.name ?? 'No status' }}
</span>
<button type="button" class="card-row__delete" aria-label="Delete card" @click="emit('delete')"> <button type="button" class="card-row__delete" aria-label="Delete card" @click="emit('delete')">
</button> </button>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import type { Card } from '../types'
defineProps<{ card: Card }>()
</script>
<template>
<div class="kanban-card">{{ card.text }}</div>
</template>
+1 -1
View File
@@ -14,7 +14,7 @@ const router = createRouter({
path: '/projects/:id(\\d+)', path: '/projects/:id(\\d+)',
name: 'project', name: 'project',
component: () => import('../views/ProjectView.vue'), component: () => import('../views/ProjectView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true, wide: true },
}, },
{ {
path: '/profile', path: '/profile',
+11 -6
View File
@@ -3,10 +3,11 @@ import { ref } from 'vue'
import { apiRequest } from '../lib/api' import { apiRequest } from '../lib/api'
import type { Card } from '../types' import type { Card } from '../types'
type CardPatch = Partial<Pick<Card, 'text' | 'complete' | 'position'>> type CardPatch = Partial<Pick<Card, 'text' | 'complete'>>
export const useCardsStore = defineStore('cards', () => { export const useCardsStore = defineStore('cards', () => {
// Held in project order (by position); mutated in place by drag-and-drop. // Every card in the project, grouped by column (inbox first) then position.
// Views re-sort as needed (the "all tasks" list is alphabetical).
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)
@@ -56,11 +57,15 @@ export const useCardsStore = defineStore('cards', () => {
cards.value = cards.value.filter((c) => c.id !== card.id) cards.value = cards.value.filter((c) => c.id !== card.id)
} }
/** Persist the current array order (call after a drag ends). */ /**
async function persistOrder(): Promise<void> { * 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[] }>( const { cards: fresh } = await apiRequest<{ cards: Card[] }>(
`/projects/${projectId.value}/cards/order`, `/projects/${projectId.value}/cards/order`,
{ method: 'PUT', auth: true, body: { card_ids: cards.value.map((c) => c.id) } }, { method: 'PUT', auth: true, body: { status_id: statusId, card_ids: cardIds } },
) )
cards.value = fresh cards.value = fresh
} }
@@ -82,7 +87,7 @@ export const useCardsStore = defineStore('cards', () => {
setComplete, setComplete,
setText, setText,
remove, remove,
persistOrder, reorderColumn,
reset, reset,
} }
}) })
+137 -20
View File
@@ -75,6 +75,12 @@ a.badge {
padding: 0 1.25rem; padding: 0 1.25rem;
} }
/* Full-bleed layout for views that need the room (e.g. the project board). */
.app__main--wide {
max-width: none;
margin: 1.5rem auto;
}
.card { .card {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -174,22 +180,21 @@ h1 {
background: var(--surface); background: var(--surface);
} }
.card-row--ghost { .card-row__status {
opacity: 0.5;
}
.card-row__handle {
cursor: grab;
color: var(--muted);
user-select: none;
padding: 0 0.15rem;
line-height: 1;
}
.card-row__check {
flex: none; flex: none;
width: 1.1rem; padding: 0.15rem 0.55rem;
height: 1.1rem; border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
border: 1px solid var(--border);
background: var(--bg);
color: var(--muted);
}
.card-row__status--none {
font-weight: 400;
font-style: italic;
} }
.card-row__text { .card-row__text {
@@ -213,11 +218,6 @@ h1 {
background: var(--bg); background: var(--bg);
} }
.card-row--done .card-row__text {
text-decoration: line-through;
color: var(--muted);
}
.card-row__delete { .card-row__delete {
flex: none; flex: none;
border: none; border: none;
@@ -348,6 +348,123 @@ h1 {
color: var(--error); color: var(--error);
} }
/* --- project detail: keep the header/prose readable on the wide layout -- */
.project__chrome {
max-width: 42rem;
}
/* --- tabs -------------------------------------------------------------- */
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border);
margin: 1.25rem 0 1rem;
}
.tabs__tab {
border: none;
background: none;
font: inherit;
color: var(--muted);
cursor: pointer;
padding: 0.5rem 0.9rem;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tabs__tab:hover {
color: var(--text);
}
.tabs__tab--active {
color: var(--text);
font-weight: 600;
border-bottom-color: var(--accent);
}
.tabs__panel--narrow {
max-width: 42rem;
}
/* --- kanban board ---------------------------------------------------- */
.kanban {
display: flex;
gap: 1rem;
align-items: flex-start;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.kanban__col {
flex: 0 0 16rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.75rem;
}
.kanban__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.6rem;
font-size: 0.85rem;
font-weight: 600;
}
.kanban__count {
color: var(--muted);
font-weight: 400;
}
.kanban__cards {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 3rem;
}
.kanban-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.55rem 0.65rem;
font-size: 0.9rem;
cursor: grab;
}
.kanban-card--ghost {
opacity: 0.5;
}
.kanban__new {
display: flex;
gap: 0.4rem;
margin-top: 0.6rem;
}
.kanban__new input {
flex: 1;
min-width: 0;
font: inherit;
font-size: 0.9rem;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text);
}
.kanban__new button[type="submit"] {
flex: none;
padding: 0.4rem 0.7rem;
font-size: 0.9rem;
border-radius: 6px;
}
/* --- confirmation modal --------------------------------------------------- */ /* --- confirmation modal --------------------------------------------------- */
.modal { .modal {
+8
View File
@@ -25,12 +25,20 @@ export interface Project {
updated_at: string updated_at: string
} }
/** A project-specific card status ("To do", "Doing", "Done", …). */
export interface CardStatus {
id: number
name: string
}
export interface Card { export interface Card {
id: number id: number
project_id: number project_id: number
text: string text: string
complete: boolean complete: boolean
position: number position: number
status_id: number | null
status: CardStatus | null
created_at: string created_at: string
updated_at: string updated_at: string
} }
+213 -87
View File
@@ -3,10 +3,11 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import CardRow from '../components/CardRow.vue' import CardRow from '../components/CardRow.vue'
import KanbanCard from '../components/KanbanCard.vue'
import { ApiError, apiRequest } from '../lib/api' import { ApiError, apiRequest } from '../lib/api'
import { useCardsStore } from '../stores/cards' import { useCardsStore } from '../stores/cards'
import { useProjectsStore } from '../stores/projects' import { useProjectsStore } from '../stores/projects'
import type { Card, Project } from '../types' import type { Card, CardStatus, Project } from '../types'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -15,9 +16,16 @@ const projects = useProjectsStore()
const projectId = Number(route.params.id) const projectId = Number(route.params.id)
const project = ref<Project | null>(null) const project = ref<Project | null>(null)
const statuses = ref<CardStatus[]>([])
const loadError = ref<string | null>(null) const loadError = ref<string | null>(null)
const actionError = ref<string | null>(null) const actionError = ref<string | null>(null)
const tabs = [
{ key: 'all', label: 'All tasks' },
{ key: 'kanban', label: 'Kanban' },
] as const
const activeTab = ref<(typeof tabs)[number]['key']>('all')
const titleDraft = ref('') const titleDraft = ref('')
const descriptionDraft = ref('') const descriptionDraft = ref('')
@@ -30,12 +38,63 @@ 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.'
return `${cards.completedCount()} of ${total} done.` return `${total} card${total === 1 ? '' : 's'}.`
}) })
// The "all tasks" list has no manual order sort by name, case-insensitively.
const sortedCards = computed(() =>
[...cards.cards].sort((a, b) => a.text.localeCompare(b.text, undefined, { sensitivity: 'base' })),
)
// --- Kanban board --------------------------------------------------------
interface Column {
key: string
title: string
statusId: number | null
cards: Card[]
}
type ColumnChange = {
added?: { element: Card; newIndex: number }
removed?: { element: Card; oldIndex: number }
moved?: { element: Card; oldIndex: number; newIndex: number }
}
const board = ref<Column[]>([])
function buildColumns(): Column[] {
const defs: Omit<Column, 'cards'>[] = [
{ key: 'inbox', title: 'Inbox', statusId: null },
...statuses.value.map((s) => ({ key: `status-${s.id}`, title: s.name, statusId: s.id })),
]
return defs.map((def) => ({
...def,
cards: cards.cards.filter((card) => card.status_id === def.statusId),
}))
}
function rebuildBoard() {
board.value = buildColumns()
}
// Re-derive the columns whenever the underlying cards or the status set change
// (e.g. after a move is persisted, or a failed move is rolled back).
watch([() => cards.cards, statuses], rebuildBoard, { deep: true })
function onColumnChange(change: ColumnChange, column: Column) {
// `added` (card dragged in from another column) or `moved` (reordered within
// this one): persist this column's new id order. The source column, if any,
// is re-packed server-side. `removed` needs no action here.
if (change.added || change.moved) {
void run(cards.reorderColumn(column.statusId, column.cards.map((c) => c.id)))
}
}
watch(project, (value) => { watch(project, (value) => {
if (value) { if (value) {
titleDraft.value = value.title titleDraft.value = value.title
@@ -65,11 +124,14 @@ function onKeydown(event: KeyboardEvent) {
async function load() { async function load() {
loadError.value = null loadError.value = null
try { try {
const [{ project: fetched }] = await Promise.all([ const [{ project: fetched }, { statuses: fetchedStatuses }] = await Promise.all([
apiRequest<{ project: Project }>(`/projects/${projectId}`, { auth: true }), apiRequest<{ project: Project }>(`/projects/${projectId}`, { auth: true }),
apiRequest<{ statuses: CardStatus[] }>(`/projects/${projectId}/statuses`, { auth: true }),
cards.load(projectId), cards.load(projectId),
]) ])
project.value = fetched project.value = fetched
statuses.value = fetchedStatuses
rebuildBoard()
} catch (e) { } catch (e) {
if (e instanceof ApiError && e.status === 404) { if (e instanceof ApiError && e.status === 404) {
loadError.value = 'That project does not exist.' loadError.value = 'That project does not exist.'
@@ -144,11 +206,6 @@ async function run(op: Promise<unknown>) {
} }
} }
function onReorder(event: { oldIndex?: number; newIndex?: number }) {
if (event.oldIndex === event.newIndex) return
void run(cards.persistOrder())
}
async function onCreate() { async function onCreate() {
submitting.value = true submitting.value = true
actionError.value = null actionError.value = null
@@ -161,101 +218,170 @@ 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>
<section class="card"> <section class="card project">
<p><RouterLink to="/">&larr; All projects</RouterLink></p> <p><RouterLink to="/">&larr; All projects</RouterLink></p>
<p v-if="loadError" class="form-error">{{ loadError }}</p> <p v-if="loadError" class="form-error">{{ loadError }}</p>
<template v-else-if="project"> <template v-else-if="project">
<div class="project-head"> <div class="project__chrome">
<h1 class="project-head__title"> <div class="project-head">
<input <h1 class="project-head__title">
v-model="titleDraft" <input
type="text" v-model="titleDraft"
maxlength="255" type="text"
aria-label="Project title" maxlength="255"
@blur="saveTitle" aria-label="Project title"
@keyup.enter="($event.target as HTMLInputElement).blur()" @blur="saveTitle"
/> @keyup.enter="($event.target as HTMLInputElement).blur()"
</h1> />
</h1>
<div class="menu"> <div class="menu">
<button <button
type="button" type="button"
class="menu__toggle" class="menu__toggle"
aria-haspopup="true" aria-haspopup="true"
:aria-expanded="menuOpen" :aria-expanded="menuOpen"
@click="menuOpen = !menuOpen" @click="menuOpen = !menuOpen"
> >
Manage &#9662; Manage &#9662;
</button> </button>
<template v-if="menuOpen"> <template v-if="menuOpen">
<div class="menu__backdrop" @click="menuOpen = false" /> <div class="menu__backdrop" @click="menuOpen = false" />
<ul class="menu__list" role="menu"> <ul class="menu__list" role="menu">
<li role="none"> <li role="none">
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
class="menu__item menu__item--danger" class="menu__item menu__item--danger"
@click="askDelete" @click="askDelete"
> >
Delete project Delete project
</button> </button>
</li> </li>
</ul> </ul>
</template> </template>
</div>
</div> </div>
<textarea
v-model="descriptionDraft"
class="project-head__desc"
rows="2"
maxlength="2000"
placeholder="Add a description"
aria-label="Project description"
@blur="saveDescription"
/>
</div> </div>
<textarea
v-model="descriptionDraft"
class="project-head__desc"
rows="2"
maxlength="2000"
placeholder="Add a description"
aria-label="Project description"
@blur="saveDescription"
/>
<p class="muted">{{ summary }}</p>
<p v-if="actionError" class="form-error">{{ actionError }}</p> <p v-if="actionError" class="form-error">{{ actionError }}</p>
<p v-if="cards.loading && !cards.loaded" class="muted">Loading</p> <div class="tabs" role="tablist">
<button
<draggable v-for="tab in tabs"
v-else-if="cards.cards.length" :key="tab.key"
:list="cards.cards" type="button"
item-key="id" role="tab"
tag="ul" :aria-selected="activeTab === tab.key"
class="cards" class="tabs__tab"
handle=".card-row__handle" :class="{ 'tabs__tab--active': activeTab === tab.key }"
ghost-class="card-row--ghost" @click="activeTab = tab.key"
:animation="150" >
@end="onReorder" {{ tab.label }}
>
<template #item="{ element }: { element: Card }">
<CardRow
:card="element"
@toggle="(v) => run(cards.setComplete(element, v))"
@save-text="(v) => run(cards.setText(element, v))"
@delete="run(cards.remove(element))"
/>
</template>
</draggable>
<form class="form form--new-card" @submit.prevent="onCreate">
<label>
<span>New card</span>
<input v-model="newText" type="text" maxlength="1000" required />
</label>
<button type="submit" :disabled="submitting">
{{ submitting ? 'Adding…' : 'Add card' }}
</button> </button>
</form> </div>
<!-- Tab: All tasks -->
<div v-show="activeTab === 'all'" class="tabs__panel tabs__panel--narrow" role="tabpanel">
<p class="muted">{{ summary }}</p>
<p v-if="cards.loading && !cards.loaded" class="muted">Loading</p>
<ul v-else-if="sortedCards.length" class="cards">
<CardRow
v-for="card in sortedCards"
:key="card.id"
:card="card"
@save-text="(v) => run(cards.setText(card, v))"
@delete="run(cards.remove(card))"
/>
</ul>
<form class="form form--new-card" @submit.prevent="onCreate">
<label>
<span>New card</span>
<input v-model="newText" type="text" maxlength="1000" required />
</label>
<button type="submit" :disabled="submitting">
{{ submitting ? 'Adding…' : 'Add card' }}
</button>
</form>
</div>
<!-- Tab: Kanban -->
<div v-show="activeTab === 'kanban'" class="tabs__panel" role="tabpanel">
<p v-if="cards.loading && !cards.loaded" class="muted">Loading</p>
<div v-else class="kanban">
<section v-for="column in board" :key="column.key" class="kanban__col">
<header class="kanban__head">
<span class="kanban__title">{{ column.title }}</span>
<span class="kanban__count">{{ column.cards.length }}</span>
</header>
<draggable
:list="column.cards"
:group="{ name: 'kanban' }"
item-key="id"
class="kanban__cards"
ghost-class="kanban-card--ghost"
:animation="150"
@change="(e: ColumnChange) => onColumnChange(e, column)"
>
<template #item="{ element }: { element: Card }">
<KanbanCard :card="element" />
</template>
</draggable>
<form
v-if="column.statusId === null"
class="kanban__new"
@submit.prevent="onCreateInbox"
>
<input
v-model="newInboxText"
type="text"
maxlength="1000"
required
placeholder="New card"
aria-label="New card"
/>
<button type="submit" :disabled="addingToInbox">Add</button>
</form>
</section>
</div>
</div>
</template> </template>
</section> </section>