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:
@@ -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 |
|
||||
| 7 | Email verification (magic links) + profile page (resend, change email) | ✅ 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
|
||||
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
|
||||
```
|
||||
|
||||
This builds a PHP 8.3 + Apache image, applies migrations, and serves the API at
|
||||
<http://localhost:8080> (e.g. `curl http://localhost:8080/api/health`).
|
||||
This runs a multi-stage build — a Node stage compiles the Vue frontend, then a
|
||||
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
|
||||
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
|
||||
`MAIL_TRANSPORT=mail` or `=smtp` (with `MAIL_SMTP_*`) to send for real.
|
||||
- The project directory is bind-mounted into the container, so editing PHP
|
||||
source takes effect without a rebuild (within ~2s, due to the opcache
|
||||
revalidation interval). `vendor/` is used from the host — run `composer`
|
||||
once first if it is missing (see "Run without Docker" below, or
|
||||
`docker compose run --rm --entrypoint composer app install`).
|
||||
- The image is the artifact: PHP source and the compiled frontend are copied in
|
||||
at build time, not bind-mounted. Rebuild to pick up any code change:
|
||||
`docker compose up -d --build`. For iterating on the frontend, run the Vite
|
||||
dev server on the host instead (see [Frontend](#frontend)).
|
||||
- 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),
|
||||
so they survive `docker compose restart` / `down` + `up`.
|
||||
- Rebuild only after changing the `Dockerfile`: `docker compose up -d --build`.
|
||||
named volume, mounted at `/var/www/storage`, so they survive
|
||||
`docker compose restart` / `down` + `up`.
|
||||
- `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
|
||||
(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)
|
||||
```
|
||||
|
||||
Any web server can serve the app as long as the document root is `public/` and
|
||||
unknown paths fall through to `public/index.php`.
|
||||
Any web server can serve the API as long as the document root is `public/` and
|
||||
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
|
||||
|
||||
The Vue/TypeScript PWA lives in [web/](web/) and talks to this API. With the API
|
||||
running (`docker compose up -d`):
|
||||
The Vue/TypeScript PWA lives in [web/](web/). The production build is compiled
|
||||
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
|
||||
cd web
|
||||
@@ -87,12 +95,6 @@ npm install
|
||||
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
|
||||
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 |
|
||||
| `JWT_SECRET` | auto-generated into `storage/secret.key` | Token signing key |
|
||||
| `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_FROM` / `MAIL_FROM_NAME` | `no-reply@todo.test` / `Projects` | Envelope sender |
|
||||
| `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes |
|
||||
@@ -276,6 +278,9 @@ Project representation:
|
||||
|
||||
`GET /api/projects` returns `{ "projects": [ … ] }`.
|
||||
|
||||
Creating a project also seeds it with three **statuses** — "To do", "Doing",
|
||||
"Done" (see [Statuses](#statuses)).
|
||||
|
||||
### Cards
|
||||
|
||||
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 |
|
||||
|--------|------|---------|
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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`) |
|
||||
|
||||
Create body: `text` (required, 1–1000 chars), `complete` (optional bool,
|
||||
default `false`), `position` (optional integer ≥ 0; when omitted the card is
|
||||
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.
|
||||
default `false`). `PATCH` needs at least one field.
|
||||
|
||||
`PUT …/cards/order` takes `{ "card_ids": [3, 1, 2] }` — every card in the
|
||||
project, each exactly once (`422` otherwise). It rewrites positions to `0..n-1`
|
||||
in one transaction and returns `{ "cards": [ … ] }` in the new order. This is
|
||||
what the drag-and-drop reorder in the UI calls.
|
||||
**Ordering.** `position` is a dense `0..n-1` rank *within a column* — the cards
|
||||
that share a `(project_id, status_id)`. The inbox (`status_id IS NULL`) is its
|
||||
own column. New cards go to the end of the inbox. There is no project-wide order.
|
||||
|
||||
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:
|
||||
|
||||
@@ -311,13 +329,40 @@ Card representation:
|
||||
"text": "Design homepage",
|
||||
"complete": false,
|
||||
"position": 0,
|
||||
"status_id": null,
|
||||
"status": null,
|
||||
"created_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
|
||||
|
||||
@@ -349,6 +394,8 @@ PROJECT=$(curl -s -X POST $BASE/api/projects \
|
||||
-d '{"title":"Website relaunch","description":"Q3"}' \
|
||||
| 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 \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-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/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier
|
||||
src/Http/JsonErrorHandler.php Uniform JSON error envelope
|
||||
src/Http/Controllers/ Request handlers (Auth, EmailVerification, Project, Card)
|
||||
src/Repository/ Database access (User, EmailVerification, Project, Card)
|
||||
src/Http/Controllers/ Request handlers (Auth, EmailVerification, Project, Card, CardStatus)
|
||||
src/Repository/ Database access (User, EmailVerification, Project, Card, CardStatus)
|
||||
src/Support/Validator.php Request-body validation helper
|
||||
migrations/*.sql Schema, applied by bin/migrate.php
|
||||
Dockerfile PHP 8.3 + Apache image
|
||||
docker-compose.yml Local stack: API + Mailpit; web via --profile frontend
|
||||
Dockerfile Multi-stage: Node frontend build + PHP 8.3/Apache runtime
|
||||
docker-compose.yml Local stack: app (SPA + API) + Mailpit
|
||||
docker/ Apache vhost + container entrypoint
|
||||
web/ Vue 3 + TypeScript + Vite PWA frontend
|
||||
web/ Vue 3 + TypeScript + Vite PWA frontend (dev on the host)
|
||||
```
|
||||
|
||||
## Provenance
|
||||
|
||||
@@ -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);
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Exception\ApiException;
|
||||
use App\Repository\CardRepository;
|
||||
use App\Repository\CardStatusRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Support\Validator;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
@@ -22,6 +23,7 @@ final class CardController extends Controller
|
||||
public function __construct(
|
||||
private readonly ProjectRepository $projects,
|
||||
private readonly CardRepository $cards,
|
||||
private readonly CardStatusRepository $statuses,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -82,11 +84,17 @@ final class CardController extends Controller
|
||||
if ($validator->has('complete')) {
|
||||
$fields['complete'] = $validator->optionalBool('complete');
|
||||
}
|
||||
if ($validator->has('position')) {
|
||||
$fields['position'] = $validator->optionalInt('position', 0);
|
||||
if ($validator->has('status_id')) {
|
||||
$statusId = $validator->nullableInt('status_id', 1);
|
||||
if ($statusId !== null && !$validator->failed()
|
||||
&& $this->statuses->findInProject($statusId, $projectId) === null
|
||||
) {
|
||||
$validator->add('status_id', 'That status does not belong to this project.');
|
||||
}
|
||||
$fields['status_id'] = $statusId;
|
||||
}
|
||||
if ($fields === [] && !$validator->failed()) {
|
||||
$validator->add('text', 'Provide at least one of: text, complete, position.');
|
||||
$validator->add('text', 'Provide at least one of: text, complete, status_id.');
|
||||
}
|
||||
$validator->assert();
|
||||
|
||||
@@ -111,29 +119,45 @@ final class CardController extends Controller
|
||||
/**
|
||||
* PUT /api/projects/{projectId}/cards/order
|
||||
*
|
||||
* Body: { "card_ids": [3, 1, 2] } — every card in the project, exactly once,
|
||||
* in the desired order. Positions are rewritten to 0..n-1.
|
||||
* Sets the contents and order of one status column.
|
||||
*
|
||||
* Body: { "status_id": 2 | null, "card_ids": [3, 1, 2] } — the cards that
|
||||
* should make up that column, in order. Positions are rewritten to 0..n-1;
|
||||
* any card moved in from another column is re-parented and its old column
|
||||
* re-packed. `status_id` is omitted or null for the inbox.
|
||||
*/
|
||||
public function reorder(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$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)) !== []) {
|
||||
throw new ApiException('card_ids must be an array of card IDs.', 422);
|
||||
}
|
||||
|
||||
/** @var int[] $order */
|
||||
$expected = $this->cards->idsForProject($projectId);
|
||||
$given = $order;
|
||||
sort($given);
|
||||
sort($expected);
|
||||
|
||||
if ($given !== $expected) {
|
||||
throw new ApiException('card_ids must contain every card in the project exactly once.', 422);
|
||||
if (count($order) !== count(array_unique($order))) {
|
||||
throw new ApiException('card_ids must not contain duplicates.', 422);
|
||||
}
|
||||
if (array_diff($order, $this->cards->idsForProject($projectId)) !== []) {
|
||||
throw new ApiException('Every card_id must be a card in this project.', 422);
|
||||
}
|
||||
if (array_diff($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);
|
||||
|
||||
return $this->json($response, ['cards' => array_map($this->present(...), $cards)]);
|
||||
@@ -155,7 +179,7 @@ final class CardController extends Controller
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
@@ -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>
|
||||
*/
|
||||
private function present(array $card): array
|
||||
@@ -180,6 +204,8 @@ final class CardController extends Controller
|
||||
'text' => $card['text'],
|
||||
'complete' => $card['complete'],
|
||||
'position' => $card['position'],
|
||||
'status_id' => $card['status_id'],
|
||||
'status' => $card['status'],
|
||||
'created_at' => $card['created_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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exception\ApiException;
|
||||
use App\Repository\CardStatusRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Support\Validator;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
@@ -20,8 +21,10 @@ final class ProjectController extends Controller
|
||||
private const DESCRIPTION_MAX = 2000;
|
||||
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);
|
||||
$this->statuses->seedDefaults($project['id']);
|
||||
|
||||
return $this->json($response, ['project' => $this->present($project)], 201);
|
||||
}
|
||||
|
||||
@@ -9,24 +9,41 @@ use PDO;
|
||||
/**
|
||||
* Data access for the `cards` table.
|
||||
*
|
||||
* `position` is a dense 0..n-1 rank *within a column* — the cards sharing a
|
||||
* (project_id, status_id). The inbox is the column where status_id IS NULL.
|
||||
*
|
||||
* @phpstan-type CardStatus array{id: int, name: string}
|
||||
* @phpstan-type CardRow array{
|
||||
* id: int, project_id: int, text: string, complete: bool, position: int,
|
||||
* status_id: int|null, status: CardStatus|null,
|
||||
* created_at: string, updated_at: string
|
||||
* }
|
||||
*/
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Every card in the project, grouped by column (inbox first) and ordered by
|
||||
* position within each. Consumers that want a different order (e.g. the
|
||||
* alphabetical "all tasks" list) re-sort client-side.
|
||||
*
|
||||
* @return CardRow[]
|
||||
*/
|
||||
public function allForProject(int $projectId): array
|
||||
{
|
||||
$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]);
|
||||
|
||||
@@ -38,7 +55,7 @@ final class CardRepository
|
||||
*/
|
||||
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]);
|
||||
|
||||
$row = $stmt->fetch();
|
||||
@@ -51,7 +68,8 @@ final class CardRepository
|
||||
*/
|
||||
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(
|
||||
'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
|
||||
*/
|
||||
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];
|
||||
|
||||
if (array_key_exists('text', $fields)) {
|
||||
@@ -87,18 +115,40 @@ final class CardRepository
|
||||
$sets[] = 'complete = :complete';
|
||||
$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';
|
||||
$params['position'] = $fields['position'];
|
||||
$params['position'] = $this->nextPositionInColumn($projectId, $fields['status_id']);
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare('UPDATE cards SET ' . implode(', ', $sets) . ' WHERE id = :id');
|
||||
$stmt->execute($params);
|
||||
$sql = 'UPDATE cards SET ' . implode(', ', $sets) . ' WHERE id = :id';
|
||||
|
||||
/** @var CardRow $card */
|
||||
$card = $this->findInProject($id, $projectId);
|
||||
if (!$movesColumn) {
|
||||
$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
|
||||
@@ -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[]
|
||||
*/
|
||||
public function idsForProject(int $projectId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id FROM cards WHERE project_id = :project ORDER BY position ASC, id ASC'
|
||||
);
|
||||
$stmt = $this->pdo->prepare('SELECT id FROM cards WHERE project_id = :project');
|
||||
$stmt->execute(['project' => $projectId]);
|
||||
|
||||
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 CardRow[] the project's cards in their new order
|
||||
* @return int[]
|
||||
*/
|
||||
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(
|
||||
'UPDATE cards SET position = :position,
|
||||
updated_at = ' . "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')" . '
|
||||
WHERE id = :id AND project_id = :project'
|
||||
"SELECT id FROM cards WHERE project_id = :project AND {$match} ORDER BY position ASC, id ASC"
|
||||
);
|
||||
$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();
|
||||
try {
|
||||
foreach (array_values($orderedIds) as $position => $id) {
|
||||
$stmt->execute(['position' => $position, 'id' => $id, 'project' => $projectId]);
|
||||
$sourceColumns = $this->columnsOf($projectId, $orderedIds);
|
||||
|
||||
$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();
|
||||
} catch (\Throwable $e) {
|
||||
$this->pdo->rollBack();
|
||||
@@ -149,16 +229,70 @@ final class CardRepository
|
||||
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(
|
||||
'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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @return CardRow
|
||||
@@ -170,6 +304,13 @@ final class CardRepository
|
||||
$row['complete'] = (bool) $row['complete'];
|
||||
$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 */
|
||||
return $row;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,27 @@ final class Validator
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* An integer (>= $min) or an explicit null. Only meaningful once has()
|
||||
* has confirmed the field is present; a null return is a valid value.
|
||||
*/
|
||||
public function nullableInt(string $field, int $min): ?int
|
||||
{
|
||||
if (!$this->has($field) || $this->data[$field] === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $this->data[$field];
|
||||
|
||||
if (!is_int($value) || $value < $min) {
|
||||
$this->errors[$field][] = ucfirst($field) . " must be an integer of at least {$min}, or null.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function add(string $field, string $message): void
|
||||
{
|
||||
$this->errors[$field][] = $message;
|
||||
|
||||
+14
-3
@@ -7,6 +7,7 @@ use App\Auth\JwtService;
|
||||
use App\Auth\SessionPayload;
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\CardController;
|
||||
use App\Http\Controllers\CardStatusController;
|
||||
use App\Http\Controllers\EmailVerificationController;
|
||||
use App\Http\Controllers\ProjectController;
|
||||
use App\Http\JsonErrorHandler;
|
||||
@@ -15,6 +16,7 @@ use App\Mail\LogMailer;
|
||||
use App\Mail\Mailer;
|
||||
use App\Mail\PhpMailerMailer;
|
||||
use App\Repository\CardRepository;
|
||||
use App\Repository\CardStatusRepository;
|
||||
use App\Repository\EmailVerificationRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\UserRepository;
|
||||
@@ -43,6 +45,7 @@ $errorMiddleware->setDefaultErrorHandler(
|
||||
$users = new UserRepository($database->pdo());
|
||||
$projects = new ProjectRepository($database->pdo());
|
||||
$cards = new CardRepository($database->pdo());
|
||||
$cardStatuses = new CardStatusRepository($database->pdo());
|
||||
$verificationTokens = new EmailVerificationRepository($database->pdo());
|
||||
$jwt = new JwtService($config->jwtSecret, $config->jwtTtl);
|
||||
$session = new SessionPayload($jwt, $verificationTokens);
|
||||
@@ -55,8 +58,9 @@ $verifier = new EmailVerifier($verificationTokens, $users, $mailer, $config->app
|
||||
|
||||
$authController = new AuthController($users, $session, $verifier);
|
||||
$emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session);
|
||||
$projectController = new ProjectController($projects);
|
||||
$cardController = new CardController($projects, $cards);
|
||||
$projectController = new ProjectController($projects, $cardStatuses);
|
||||
$cardController = new CardController($projects, $cards, $cardStatuses);
|
||||
$cardStatusController = new CardStatusController($projects, $cardStatuses);
|
||||
$authMiddleware = new AuthMiddleware($jwt, $users);
|
||||
|
||||
// --- Routes ---------------------------------------------------------------
|
||||
@@ -66,6 +70,7 @@ $app->group('/api', function (RouteCollectorProxy $group) use (
|
||||
$emailController,
|
||||
$projectController,
|
||||
$cardController,
|
||||
$cardStatusController,
|
||||
$authMiddleware,
|
||||
) {
|
||||
$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/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->post('', [$projectController, 'store']);
|
||||
$projects->get('/{projectId:[0-9]+}', [$projectController, 'show']);
|
||||
$projects->patch('/{projectId:[0-9]+}', [$projectController, 'update']);
|
||||
$projects->delete('/{projectId:[0-9]+}', [$projectController, 'destroy']);
|
||||
|
||||
$projects->get('/{projectId:[0-9]+}/statuses', [$cardStatusController, 'index']);
|
||||
|
||||
$projects->get('/{projectId:[0-9]+}/cards', [$cardController, 'index']);
|
||||
$projects->post('/{projectId:[0-9]+}/cards', [$cardController, 'store']);
|
||||
$projects->put('/{projectId:[0-9]+}/cards/order', [$cardController, 'reorder']);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -2,7 +2,7 @@
|
||||
|
||||
Vue 3 + TypeScript + Vite PWA. Talks to the REST API in the parent directory.
|
||||
|
||||
## Develop on the host
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
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
|
||||
`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
|
||||
|
||||
```bash
|
||||
@@ -32,6 +21,11 @@ npm run build # type-checks, then emits dist/
|
||||
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
|
||||
|
||||
```
|
||||
@@ -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/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/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/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,
|
||||
ProfileView, VerifyEmailView
|
||||
```
|
||||
|
||||
## Project detail
|
||||
|
||||
`/projects/:id` shows one project. The title and description are inline-editable
|
||||
(saved on blur via `PATCH /api/projects/:id`; the description shows an "Add a
|
||||
description" placeholder when empty). A **Manage** 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.
|
||||
`/projects/:id` shows one project. It renders on a **full-width** layout (the
|
||||
route sets `meta.wide`, which widens `.app__main` in `App.vue`). The title and
|
||||
description are inline-editable (saved on blur via `PATCH /api/projects/:id`; the
|
||||
description shows an "Add a description" placeholder when empty). A **Manage**
|
||||
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
|
||||
delete button, and a drag handle. Reordering uses `vuedraggable`; on drop the
|
||||
whole new order is persisted via `PUT /api/projects/:id/cards/order`, and the
|
||||
server response replaces local state.
|
||||
Below the header are two tabs (local `activeTab` state, `v-show` so both stay
|
||||
mounted):
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
+3
-2
@@ -1,5 +1,5 @@
|
||||
<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 { useCardsStore } from './stores/cards'
|
||||
import { useProjectsStore } from './stores/projects'
|
||||
@@ -8,6 +8,7 @@ const auth = useAuthStore()
|
||||
const projects = useProjectsStore()
|
||||
const cards = useCardsStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
async function onLogout() {
|
||||
auth.logout()
|
||||
@@ -31,7 +32,7 @@ async function onLogout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app__main">
|
||||
<main class="app__main" :class="{ 'app__main--wide': route.meta.wide }">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Card } from '../types'
|
||||
|
||||
const props = defineProps<{ card: Card }>()
|
||||
const emit = defineEmits<{
|
||||
toggle: [complete: boolean]
|
||||
'save-text': [text: string]
|
||||
delete: []
|
||||
}>()
|
||||
@@ -28,17 +27,7 @@ function commit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="card-row" :class="{ 'card-row--done': card.complete }">
|
||||
<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)"
|
||||
/>
|
||||
|
||||
<li class="card-row">
|
||||
<input
|
||||
v-model="text"
|
||||
class="card-row__text"
|
||||
@@ -49,6 +38,10 @@ function commit() {
|
||||
@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>
|
||||
|
||||
@@ -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>
|
||||
@@ -14,7 +14,7 @@ const router = createRouter({
|
||||
path: '/projects/:id(\\d+)',
|
||||
name: 'project',
|
||||
component: () => import('../views/ProjectView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
meta: { requiresAuth: true, wide: true },
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
|
||||
+11
-6
@@ -3,10 +3,11 @@ import { ref } from 'vue'
|
||||
import { apiRequest } from '../lib/api'
|
||||
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', () => {
|
||||
// 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 projectId = ref<number | null>(null)
|
||||
const loading = ref(false)
|
||||
@@ -56,11 +57,15 @@ export const useCardsStore = defineStore('cards', () => {
|
||||
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[] }>(
|
||||
`/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
|
||||
}
|
||||
@@ -82,7 +87,7 @@ export const useCardsStore = defineStore('cards', () => {
|
||||
setComplete,
|
||||
setText,
|
||||
remove,
|
||||
persistOrder,
|
||||
reorderColumn,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
|
||||
+137
-20
@@ -75,6 +75,12 @@ a.badge {
|
||||
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 {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
@@ -174,22 +180,21 @@ h1 {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.card-row--ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.card-row__handle {
|
||||
cursor: grab;
|
||||
color: var(--muted);
|
||||
user-select: none;
|
||||
padding: 0 0.15rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.card-row__check {
|
||||
.card-row__status {
|
||||
flex: none;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
padding: 0.15rem 0.55rem;
|
||||
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 {
|
||||
@@ -213,11 +218,6 @@ h1 {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.card-row--done .card-row__text {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.card-row__delete {
|
||||
flex: none;
|
||||
border: none;
|
||||
@@ -348,6 +348,123 @@ h1 {
|
||||
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 --------------------------------------------------- */
|
||||
|
||||
.modal {
|
||||
|
||||
@@ -25,12 +25,20 @@ export interface Project {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** A project-specific card status ("To do", "Doing", "Done", …). */
|
||||
export interface CardStatus {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Card {
|
||||
id: number
|
||||
project_id: number
|
||||
text: string
|
||||
complete: boolean
|
||||
position: number
|
||||
status_id: number | null
|
||||
status: CardStatus | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
+154
-28
@@ -3,10 +3,11 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import draggable from 'vuedraggable'
|
||||
import CardRow from '../components/CardRow.vue'
|
||||
import KanbanCard from '../components/KanbanCard.vue'
|
||||
import { ApiError, apiRequest } from '../lib/api'
|
||||
import { useCardsStore } from '../stores/cards'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import type { Card, Project } from '../types'
|
||||
import type { Card, CardStatus, Project } from '../types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -15,9 +16,16 @@ const projects = useProjectsStore()
|
||||
|
||||
const projectId = Number(route.params.id)
|
||||
const project = ref<Project | null>(null)
|
||||
const statuses = ref<CardStatus[]>([])
|
||||
const loadError = 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 descriptionDraft = ref('')
|
||||
|
||||
@@ -30,12 +38,63 @@ const cancelButton = ref<HTMLButtonElement>()
|
||||
const newText = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const newInboxText = ref('')
|
||||
const addingToInbox = ref(false)
|
||||
|
||||
const summary = computed(() => {
|
||||
const total = cards.cards.length
|
||||
if (total === 0) return 'No cards yet.'
|
||||
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) => {
|
||||
if (value) {
|
||||
titleDraft.value = value.title
|
||||
@@ -65,11 +124,14 @@ function onKeydown(event: KeyboardEvent) {
|
||||
async function load() {
|
||||
loadError.value = null
|
||||
try {
|
||||
const [{ project: fetched }] = await Promise.all([
|
||||
const [{ project: fetched }, { statuses: fetchedStatuses }] = await Promise.all([
|
||||
apiRequest<{ project: Project }>(`/projects/${projectId}`, { auth: true }),
|
||||
apiRequest<{ statuses: CardStatus[] }>(`/projects/${projectId}/statuses`, { auth: true }),
|
||||
cards.load(projectId),
|
||||
])
|
||||
project.value = fetched
|
||||
statuses.value = fetchedStatuses
|
||||
rebuildBoard()
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
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() {
|
||||
submitting.value = true
|
||||
actionError.value = null
|
||||
@@ -161,15 +218,31 @@ async function onCreate() {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// New cards always land in the inbox (no status), so this just adds one.
|
||||
async function onCreateInbox() {
|
||||
if (!newInboxText.value.trim()) return
|
||||
addingToInbox.value = true
|
||||
actionError.value = null
|
||||
try {
|
||||
await cards.add(newInboxText.value)
|
||||
newInboxText.value = ''
|
||||
} catch (e) {
|
||||
actionError.value = e instanceof ApiError ? e.message : 'Could not add the card.'
|
||||
} finally {
|
||||
addingToInbox.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card">
|
||||
<section class="card project">
|
||||
<p><RouterLink to="/">← All projects</RouterLink></p>
|
||||
|
||||
<p v-if="loadError" class="form-error">{{ loadError }}</p>
|
||||
|
||||
<template v-else-if="project">
|
||||
<div class="project__chrome">
|
||||
<div class="project-head">
|
||||
<h1 class="project-head__title">
|
||||
<input
|
||||
@@ -220,32 +293,40 @@ async function onCreate() {
|
||||
aria-label="Project description"
|
||||
@blur="saveDescription"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="muted">{{ summary }}</p>
|
||||
<p v-if="actionError" class="form-error">{{ actionError }}</p>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === tab.key"
|
||||
class="tabs__tab"
|
||||
:class="{ 'tabs__tab--active': activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<draggable
|
||||
v-else-if="cards.cards.length"
|
||||
:list="cards.cards"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
class="cards"
|
||||
handle=".card-row__handle"
|
||||
ghost-class="card-row--ghost"
|
||||
:animation="150"
|
||||
@end="onReorder"
|
||||
>
|
||||
<template #item="{ element }: { element: Card }">
|
||||
<ul v-else-if="sortedCards.length" class="cards">
|
||||
<CardRow
|
||||
:card="element"
|
||||
@toggle="(v) => run(cards.setComplete(element, v))"
|
||||
@save-text="(v) => run(cards.setText(element, v))"
|
||||
@delete="run(cards.remove(element))"
|
||||
v-for="card in sortedCards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
@save-text="(v) => run(cards.setText(card, v))"
|
||||
@delete="run(cards.remove(card))"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</ul>
|
||||
|
||||
<form class="form form--new-card" @submit.prevent="onCreate">
|
||||
<label>
|
||||
@@ -256,6 +337,51 @@ async function onCreate() {
|
||||
{{ 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>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user