Compare commits
221 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aff10557c | |||
| e8fe24f755 | |||
| fc170ccf10 | |||
| 3ae9d87e6e | |||
| 3dbede6bc2 | |||
| ba238ca962 | |||
| 7cabebc338 | |||
| 74cde67a54 | |||
| 900f85f8ac | |||
| 00a7ce772e | |||
| 71dee23028 | |||
| 91716e628a | |||
| 002af9d1f8 | |||
| d8d8035850 | |||
| 704b159d48 | |||
| c1bddb47c4 | |||
| a21ab85576 | |||
| 7ddf6967ce | |||
| 404cf67f35 | |||
| 50d2512123 | |||
| c689b8c0e9 | |||
| acdaf8d07f | |||
| 77c56f7a9d | |||
| 030472c2da | |||
| f1eb6a9ba5 | |||
| 285a1323ad | |||
| da3e078fbc | |||
| 0def81ab42 | |||
| 546680017d | |||
| 3efb7e175d | |||
| 56076c4daa | |||
| aeb1b084d9 | |||
| 6e02ac874f | |||
| dd131ee740 | |||
| cad5a980c5 | |||
| 17bfd3e9d8 | |||
| d90aa75468 | |||
| 7a43f794e5 | |||
| af3f1a5367 | |||
| ec6e90ef5b | |||
| 3c59f47f81 | |||
| 76f65a95dd | |||
| a0aab6571f | |||
| 6e72f24f0a | |||
| d447e2d8a8 | |||
| a9a0c4d477 | |||
| c0c86a5859 | |||
| faca2670a4 | |||
| c68bbb9460 | |||
| 30da072d96 | |||
| 1cdfa21259 | |||
| d37ac821f0 | |||
| 150ca63fc0 | |||
| d082836529 | |||
| 69d3d2be15 | |||
| 57504c941d | |||
| 4530004d87 | |||
| 1948d09d16 | |||
| 4c24f0387c | |||
| 0209638552 | |||
| 2b6ea1b4a4 | |||
| 3575282dc2 | |||
| 882d0c828f | |||
| 75e7cf9047 | |||
| 76b2cbde1d | |||
| 6c2fa63cac | |||
| a4fb05a175 | |||
| 0678cefd13 | |||
| 53c98102d2 | |||
| 0d4026a968 | |||
| d0da77a004 | |||
| 6bce1e6782 | |||
| 506bfd63dd | |||
| f45f1d8807 | |||
| ede32551be | |||
| 71d899cbdc | |||
| 09e9b3f4d4 | |||
| e54ea89b1e | |||
| 3782120b49 | |||
| 28e444c6c5 | |||
| d3ee4365e0 | |||
| e18cad9c6a | |||
| 537b847acb | |||
| 3900bc362c | |||
| ed0c13907c | |||
| f3881e8c7c | |||
| 6ed137f49e | |||
| e005e76f5b | |||
| b7242caf51 | |||
| 6efe09d40c | |||
| 5c8fe3cd81 | |||
| 4b55218c69 | |||
| af6004f731 | |||
| 18cb35beff | |||
| dbaf22500e | |||
| 4fad3c43f0 | |||
| e4badbdefc | |||
| 285d35601b | |||
| 9b3a587eab | |||
| 8511aebb53 | |||
| 6e1f5ea50f | |||
| 70025e1e71 | |||
| 40384d91dd | |||
| d3e88be70f | |||
| 03f6e1d7ed | |||
| aab1bb37dc | |||
| 9323c608ee | |||
| eead013ccd | |||
| 4f3db60ed2 | |||
| 6d17e5f84d | |||
| d452dd9b35 | |||
| e5c03383fe | |||
| 5e7a80e377 | |||
| 5d63f06863 | |||
| d0e3772c34 | |||
| a9e6788b0b | |||
| 48edb0391e | |||
| 93234aae29 | |||
| cde7be9f2a | |||
| 04ed0c50e2 | |||
| 67e486df46 | |||
| d408464e91 | |||
| 1bfa44a0ed | |||
| 303c986d40 | |||
| fcad638549 | |||
| 604d4f6005 | |||
| 63bfff417b | |||
| 8eb527957b | |||
| e2ae093ed8 | |||
| 03d5b59b48 | |||
| 2e38af565a | |||
| 7258b3fd03 | |||
| 6ec31b6c51 | |||
| 0a88a86bb3 | |||
| 6a62cf64bf | |||
| c052ddc5af | |||
| e7b0f65686 | |||
| b8f70212a1 | |||
| 184e4ea2a5 | |||
| 04c33cb1aa | |||
| 49f694d1fb | |||
| 98c00d3732 | |||
| 60a1b8dccf | |||
| 5efa7b8a16 | |||
| e7ff817c63 | |||
| fb80146430 | |||
| b49699175d | |||
| e700e1d3cf | |||
| de035bd032 | |||
| 4267aae4e5 | |||
| c84b84b153 | |||
| 0188e730e8 | |||
| 6e52a331bc | |||
| 8e57789dd7 | |||
| 8ed747c6a7 | |||
| dd02bddb07 | |||
| 6ebcc10405 | |||
| 325917a98e | |||
| d74500f901 | |||
| 7d40a2cd56 | |||
| 873efe199f | |||
| 27caaa9787 | |||
| c9120848f5 | |||
| 83ca506702 | |||
| 65ca79f2bd | |||
| 194f18c8ed | |||
| 282e6430d4 | |||
| 78c950d2ee | |||
| 3e7c6ad712 | |||
| 47240dafcc | |||
| 83a7202861 | |||
| 09baf2949f | |||
| f6053068be | |||
| e58b150ab2 | |||
| e7ae41362e | |||
| ffcfb41c7e | |||
| b2d026f217 | |||
| 4e1138f8ce | |||
| e6fc3eaf2c | |||
| b4d71b0f80 | |||
| 0a29127f7e | |||
| 0c9db7bcdb | |||
| d6dc1c9b57 | |||
| cd3606c0e9 | |||
| 260eac903e | |||
| 9d0475e8ec | |||
| 04e9c95c52 | |||
| de11292203 | |||
| 825b23adec | |||
| 2460a1368d | |||
| 4a76d6043a | |||
| 0f43c75b24 | |||
| 3c6a41a80a | |||
| 146e0164e7 | |||
| 984be697ac | |||
| 7181437625 | |||
| 7e235ffd3e | |||
| b0d2c247df | |||
| e9a5a10524 | |||
| df113bd7ac | |||
| 0ee3b970cb | |||
| 5a72f85989 | |||
| d3c33a6c5d | |||
| 331a6d7f34 | |||
| 0d971cda15 | |||
| 914527edc6 | |||
| ff513e1712 | |||
| 1a91b8a242 | |||
| 2bce469ed2 | |||
| fbb7a297a6 | |||
| 8442afbf02 | |||
| 869a2c6e50 | |||
| 126f84962a | |||
| daad9438ba | |||
| fd1c22191b | |||
| 37c80121ed | |||
| 6ad1304efd | |||
| df8f31d14d | |||
| b508273a52 | |||
| b490db13b1 | |||
| 19408f6282 |
@@ -0,0 +1,11 @@
|
|||||||
|
# cargo-nextest configuration. https://nexte.st/book/configuration
|
||||||
|
#
|
||||||
|
# nextest runs each test in its own process: live per-test output, and a hard
|
||||||
|
# per-test timeout so a genuinely wedged test is killed + named rather than
|
||||||
|
# stalling the whole run.
|
||||||
|
|
||||||
|
[profile.default]
|
||||||
|
# Warn at 60s, terminate a test after 2×60s = 120s. The slowest real test is a
|
||||||
|
# couple of seconds (each #[sqlx::test] provisions its own temp DB), so this
|
||||||
|
# only ever fires on an actual hang.
|
||||||
|
slow-timeout = { period = "60s", terminate-after = 2 }
|
||||||
+18
-1
@@ -1,5 +1,22 @@
|
|||||||
# Connection string for local development and tests.
|
# Copy to .env for local development: cp .env.example .env
|
||||||
|
# These defaults match the services in docker-compose.yml.
|
||||||
|
|
||||||
|
# PostgreSQL connection string (used for local dev and the test suite).
|
||||||
# The role must be allowed to CREATE DATABASE (sqlx::test provisions temp DBs).
|
# The role must be allowed to CREATE DATABASE (sqlx::test provisions temp DBs).
|
||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/cms_dev
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/cms_dev
|
||||||
|
|
||||||
|
# HTTP bind address.
|
||||||
BIND_ADDR=0.0.0.0:8080
|
BIND_ADDR=0.0.0.0:8080
|
||||||
|
|
||||||
|
# User-facing product name (OpenAPI title, page title). Set the real name at deploy time.
|
||||||
APP_NAME=Collection Management System
|
APP_NAME=Collection Management System
|
||||||
|
|
||||||
|
# Local development is plain HTTP. Browsers drop `Secure` cookies on http://localhost,
|
||||||
|
# so the session cookie must NOT be Secure-only or login will silently fail. Set this
|
||||||
|
# back to `true` (the default) for any HTTPS deployment.
|
||||||
|
SESSION_COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# Meilisearch (matches docker-compose.yml). Both must be set to enable search;
|
||||||
|
# leave them unset to run with search disabled.
|
||||||
|
MEILI_URL=http://localhost:7700
|
||||||
|
MEILI_MASTER_KEY=masterKey
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
web:
|
web:
|
||||||
runs-on: ubuntu-latest
|
runs-on: aceofba-cluster
|
||||||
|
container:
|
||||||
|
image: ghcr.io/catthehacker/ubuntu:act-22.04
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: web
|
working-directory: web
|
||||||
@@ -15,15 +17,17 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 11
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: web/pnpm-lock.yaml
|
cache-dependency-path: web/pnpm-lock.yaml
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm typecheck
|
- run: pnpm typecheck
|
||||||
- run: pnpm lint
|
- run: pnpm lint
|
||||||
|
- run: pnpm exec playwright install --with-deps chromium
|
||||||
- run: pnpm test
|
- run: pnpm test
|
||||||
- run: pnpm build
|
- run: pnpm build
|
||||||
- run: pnpm check:size
|
- run: pnpm check:size
|
||||||
|
- run: pnpm check:colors
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/target
|
/target
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Local-only Docker Compose overrides (machine-specific port remaps, etc.)
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
|
|||||||
@@ -4,22 +4,28 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Freshly scaffolded Rust binary crate (edition 2024). `src/main.rs` is still the `cargo new` "Hello, world!" stub and `Cargo.toml` has no dependencies yet. There is no architecture to document — update this file as real structure emerges.
|
Rust (edition 2024) workspace + React SPA collection-management system. Backend crates: `domain`, `db`, `api`, `auth`, `search`, `server` (axum 0.8 + sqlx/Postgres + Meilisearch). Frontend in `web/` (React 19 + Vite + pnpm). Tests need the docker-compose stack up (Postgres on **:5442**, Meilisearch on **:7700**); each `#[sqlx::test]` provisions its own temp DB.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build # build
|
just check # fmt + lint + test — the standard pre-commit gate
|
||||||
cargo run # run the binary
|
docker compose up -d # start Postgres (:5442) + Meilisearch (:7700) for tests
|
||||||
cargo test # run all tests
|
cargo build --workspace # build
|
||||||
cargo test <name> # run a single test by name substring
|
cargo run -p server # run the server (or: just run — loads .env)
|
||||||
cargo +nightly fmt # format — always nightly, not stable
|
cargo nextest run --workspace # run all tests — PREFERRED (per-test isolation, live output, hang timeouts)
|
||||||
cargo clippy # lint before committing
|
cargo nextest run -E 'test(<name>)' # run tests matching a name substring
|
||||||
|
cargo test --workspace --doc # doctests (nextest does not run these)
|
||||||
|
cargo +nightly fmt # format — always nightly, not stable
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings # lint before committing
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(`just test` runs nextest + doctests; config in `.config/nextest.toml`.)
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **CLI args & env vars:** use `clap` with the `derive` feature.
|
- **CLI args & env vars:** use `clap` with the `derive` feature.
|
||||||
- **Code navigation:** prefer the insikt LSP server over grep/glob — it resolves macro-generated symbols that text search misses. (insikt runs standalone, not via the gateway MCP.)
|
- **Code navigation:** prefer the insikt LSP server over grep/glob — it resolves macro-generated symbols that text search misses. (insikt runs standalone, not via the gateway MCP.)
|
||||||
- **Dependencies:** manage via the `cargo-mcp` server rather than editing `Cargo.toml` by hand.
|
- **Dependencies:** manage via the `cargo-mcp` server rather than editing `Cargo.toml` by hand.
|
||||||
- **Formatting:** `cargo +nightly fmt` (nightly toolchain required).
|
- **Formatting:** `cargo +nightly fmt` (nightly toolchain required).
|
||||||
|
- **Frontend guardrails:** before touching `web/`, read **[web/GUARDRAILS.md](web/GUARDRAILS.md)** — it covers the CI gate (`check:size` 250 KB-gz budget, `check:colors` design-token enforcement) and the test-harness quirks (MSW `onUnhandledRequest: "error"`, the jsdom/storybook vitest split, RTL accessible-name collisions, Storybook nested-router and portal handling, and the `components/ui/` code-style split).
|
||||||
|
|||||||
Generated
+2
@@ -564,6 +564,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"time",
|
"time",
|
||||||
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2076,6 +2077,7 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"db",
|
"db",
|
||||||
"domain",
|
"domain",
|
||||||
|
"dotenvy",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"memory-serve",
|
"memory-serve",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|||||||
@@ -28,4 +28,5 @@ argon2 = "0.5"
|
|||||||
tower-sessions = "0.14"
|
tower-sessions = "0.14"
|
||||||
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
|
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
|
dotenvy = "0.15"
|
||||||
memory-serve = "2.1"
|
memory-serve = "2.1"
|
||||||
|
|||||||
@@ -1,3 +1,90 @@
|
|||||||
# Biggus Dickus
|
# Biggus Dickus
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
A museum collection-management system: a Rust (axum + sqlx + Postgres) API with a
|
||||||
|
React + TypeScript admin SPA and optional Meilisearch-backed full-text search.
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
The whole backing stack runs from one `docker compose` file (PostgreSQL + Meilisearch).
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Docker (PostgreSQL + Meilisearch)
|
||||||
|
- Rust (stable; plus a nightly toolchain for `cargo +nightly fmt`)
|
||||||
|
- Node.js and [`pnpm`](https://pnpm.io/) (web frontend)
|
||||||
|
- [`just`](https://github.com/casey/just) — optional, for the shortcuts below
|
||||||
|
|
||||||
|
### 1. Start the backing services
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
PostgreSQL listens on `localhost:5432` (database `cms_dev`) and Meilisearch on
|
||||||
|
`localhost:7700`. Give them a few seconds to become healthy on first start.
|
||||||
|
|
||||||
|
### 2. Configure the environment
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
The defaults already match the compose services. Note **`SESSION_COOKIE_SECURE=false`**:
|
||||||
|
local development is plain HTTP, and browsers drop `Secure` cookies on `http://localhost`,
|
||||||
|
so leaving it `true` would make login silently fail. Set it back to `true` for any HTTPS
|
||||||
|
deployment.
|
||||||
|
|
||||||
|
### 3. Run the API server
|
||||||
|
```bash
|
||||||
|
just run # or: cargo run -p server
|
||||||
|
```
|
||||||
|
On startup the server connects to PostgreSQL, **runs database migrations automatically**,
|
||||||
|
ensures the Meilisearch index exists, and listens on `http://localhost:8080`. (If the
|
||||||
|
`MEILI_*` variables are unset, search is disabled and everything else still works.)
|
||||||
|
|
||||||
|
### 4. Create a login user
|
||||||
|
There is no seeded account — create one (you'll be prompted for a password, minimum 8
|
||||||
|
characters):
|
||||||
|
```bash
|
||||||
|
cargo run -p server -- create-user --email you@example.com --role admin
|
||||||
|
# non-interactive:
|
||||||
|
BOOTSTRAP_PASSWORD=changeme123 cargo run -p server -- create-user --email you@example.com --role editor
|
||||||
|
```
|
||||||
|
Roles are `admin` or `editor`.
|
||||||
|
|
||||||
|
### 5. Seed the baseline cataloguing fields (idempotent)
|
||||||
|
```bash
|
||||||
|
just seed # or: cargo run -p server -- seed
|
||||||
|
```
|
||||||
|
Populates the baseline Spectrum cataloguing vocabularies and field definitions. Safe to
|
||||||
|
re-run — the seed is idempotent.
|
||||||
|
|
||||||
|
### 6. Run the web frontend
|
||||||
|
The API server serves JSON only; in development the SPA is served by Vite, which proxies
|
||||||
|
`/api` to `:8080`:
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # http://localhost:5173
|
||||||
|
```
|
||||||
|
Open **http://localhost:5173** and sign in with the user from step 4.
|
||||||
|
|
||||||
|
### Single-binary alternative
|
||||||
|
To serve the built SPA and the API from one process (no Vite), build the web assets and
|
||||||
|
enable the `embed-web` feature:
|
||||||
|
```bash
|
||||||
|
cd web && pnpm build # outputs web/dist
|
||||||
|
cargo run -p server --features embed-web # SPA + API on http://localhost:8080
|
||||||
|
```
|
||||||
|
Assets are embedded at compile time, so rebuild `web/dist` and recompile after frontend
|
||||||
|
changes.
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
Backend tests reuse the same compose services — PostgreSQL provisions a throwaway database
|
||||||
|
per test (`sqlx::test`) and Meilisearch tests use isolated, unique index names, so they
|
||||||
|
don't touch your dev data. With `docker compose up -d` running and `.env` in place:
|
||||||
|
```bash
|
||||||
|
just test # cargo test --workspace (reads .env via dotenv)
|
||||||
|
cd web && pnpm test # frontend tests (Vitest + MSW; no services needed)
|
||||||
|
```
|
||||||
|
`just check` runs format + lint + the Rust test suite. Run `cargo test` directly only if
|
||||||
|
`DATABASE_URL`/`MEILI_URL`/`MEILI_MASTER_KEY` are exported in your shell — the Meilisearch
|
||||||
|
tests require them; `just` loads them from `.env` for you.
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ pub(crate) struct UserView {
|
|||||||
/// Desired visibility for a publish/unpublish request.
|
/// Desired visibility for a publish/unpublish request.
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub(crate) struct VisibilityRequest {
|
pub(crate) struct VisibilityRequest {
|
||||||
#[schema(value_type = String)]
|
|
||||||
pub visibility: Visibility,
|
pub visibility: Visibility,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,23 +3,25 @@
|
|||||||
use auth::{Authorized, EditCatalogue, ViewInternal};
|
use auth::{Authorized, EditCatalogue, ViewInternal};
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
use domain::{AuthorityKind, LocalizedLabel, NewAuthority};
|
use domain::{AuditActor, AuthorityId, AuthorityKind, LocalizedLabel, NewAuthority};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
admin_objects::LabelView,
|
admin_objects::LabelView,
|
||||||
admin_vocab::{CreatedId, LabelInput},
|
admin_vocab::{CreatedId, InUseView, LabelInput},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub(crate) struct AuthorityView {
|
pub(crate) struct AuthorityView {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[schema(value_type = domain::AuthorityKind)]
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub external_uri: Option<String>,
|
pub external_uri: Option<String>,
|
||||||
pub labels: Vec<LabelView>,
|
pub labels: Vec<LabelView>,
|
||||||
@@ -90,7 +92,7 @@ pub(crate) async fn list_authorities(
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub(crate) async fn create_authority(
|
pub(crate) async fn create_authority(
|
||||||
_auth: Authorized<EditCatalogue>,
|
auth: Authorized<EditCatalogue>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<NewAuthorityRequest>,
|
Json(req): Json<NewAuthorityRequest>,
|
||||||
) -> Result<(StatusCode, Json<CreatedId>), StatusCode> {
|
) -> Result<(StatusCode, Json<CreatedId>), StatusCode> {
|
||||||
@@ -116,9 +118,10 @@ pub(crate) async fn create_authority(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
let id = db::authority::create_authority(&mut tx, &new)
|
let id =
|
||||||
.await
|
db::authority::create_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &new)
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
tx.commit()
|
tx.commit()
|
||||||
.await
|
.await
|
||||||
@@ -127,9 +130,125 @@ pub(crate) async fn create_authority(
|
|||||||
Ok((StatusCode::CREATED, Json(CreatedId { id: id.to_string() })))
|
Ok((StatusCode::CREATED, Json(CreatedId { id: id.to_string() })))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn routes() -> Router<AppState> {
|
#[derive(Deserialize, ToSchema)]
|
||||||
Router::new().route(
|
pub(crate) struct UpdateAuthorityRequest {
|
||||||
"/api/admin/authorities",
|
pub external_uri: Option<String>,
|
||||||
get(list_authorities).post(create_authority),
|
pub labels: Vec<LabelInput>,
|
||||||
)
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
patch, path = "/api/admin/authorities/{id}",
|
||||||
|
request_body = UpdateAuthorityRequest,
|
||||||
|
params(("id" = String, Path, description = "Authority id (UUID)")),
|
||||||
|
responses(
|
||||||
|
(status = 204),
|
||||||
|
(status = 401),
|
||||||
|
(status = 403),
|
||||||
|
(status = 404)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn update_authority(
|
||||||
|
auth: Authorized<EditCatalogue>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(req): Json<UpdateAuthorityRequest>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
let id = id
|
||||||
|
.parse::<AuthorityId>()
|
||||||
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let labels: Vec<LocalizedLabel> = req
|
||||||
|
.labels
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| LocalizedLabel {
|
||||||
|
lang: l.lang,
|
||||||
|
label: l.label,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut tx = state
|
||||||
|
.db
|
||||||
|
.pool()
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let existed = db::authority::update_authority(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::User(auth.user.id.to_uuid()),
|
||||||
|
id,
|
||||||
|
req.external_uri.as_deref(),
|
||||||
|
&labels,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if existed {
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
} else {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
Err(StatusCode::NOT_FOUND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete, path = "/api/admin/authorities/{id}",
|
||||||
|
params(("id" = String, Path, description = "Authority id (UUID)")),
|
||||||
|
responses(
|
||||||
|
(status = 204),
|
||||||
|
(status = 401),
|
||||||
|
(status = 403),
|
||||||
|
(status = 404),
|
||||||
|
(status = 409, body = InUseView, description = "Referenced by catalogue objects")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn delete_authority(
|
||||||
|
auth: Authorized<EditCatalogue>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
let Ok(id) = id.parse::<AuthorityId>() else {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(mut tx) = state.db.pool().begin().await else {
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
match db::authority::delete_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
},
|
||||||
|
Ok(db::DeleteOutcome::InUse { count }) => {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
(StatusCode::CONFLICT, Json(InUseView { count })).into_response()
|
||||||
|
}
|
||||||
|
Ok(db::DeleteOutcome::NotFound) => {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
StatusCode::NOT_FOUND.into_response()
|
||||||
|
}
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/api/admin/authorities",
|
||||||
|
get(list_authorities).post(create_authority),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/authorities/{id}",
|
||||||
|
axum::routing::patch(update_authority).delete(delete_authority),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+379
-30
@@ -6,14 +6,18 @@ use axum::{
|
|||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::{IntoResponse, Response},
|
||||||
routing::{get, put},
|
routing::{get, put},
|
||||||
};
|
};
|
||||||
use domain::{AuditActor, CatalogueObject, ObjectId, ObjectInput, Visibility};
|
use domain::{
|
||||||
|
AuditActor, AuthorityKind, CatalogueObject, FieldType, LocalizedLabel, NewFieldDefinition,
|
||||||
|
ObjectId, ObjectInput, Visibility, VocabularyId,
|
||||||
|
};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::{AppState, pagination::Pagination, reindex};
|
use crate::{AppState, admin_vocab::LabelInput, reindex};
|
||||||
|
|
||||||
/// A localized label `{ lang, label }` (shared across admin views).
|
/// A localized label `{ lang, label }` (shared across admin views).
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -36,10 +40,15 @@ pub(crate) struct AdminObjectView {
|
|||||||
/// `YYYY-MM-DD` or null.
|
/// `YYYY-MM-DD` or null.
|
||||||
pub recording_date: Option<String>,
|
pub recording_date: Option<String>,
|
||||||
/// "draft" | "internal" | "public".
|
/// "draft" | "internal" | "public".
|
||||||
|
#[schema(value_type = domain::Visibility)]
|
||||||
pub visibility: String,
|
pub visibility: String,
|
||||||
/// Flexible field values (key -> value).
|
/// Flexible field values (key -> value).
|
||||||
#[schema(value_type = Object)]
|
#[schema(value_type = std::collections::HashMap<String, serde_json::Value>)]
|
||||||
pub fields: serde_json::Value,
|
pub fields: serde_json::Value,
|
||||||
|
/// RFC3339 UTC timestamp.
|
||||||
|
pub created_at: String,
|
||||||
|
/// RFC3339 UTC timestamp.
|
||||||
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdminObjectView {
|
impl AdminObjectView {
|
||||||
@@ -56,6 +65,14 @@ impl AdminObjectView {
|
|||||||
recording_date: o.recording_date.map(format_date),
|
recording_date: o.recording_date.map(format_date),
|
||||||
visibility: o.visibility.as_str().to_owned(),
|
visibility: o.visibility.as_str().to_owned(),
|
||||||
fields: o.fields.clone(),
|
fields: o.fields.clone(),
|
||||||
|
created_at: o
|
||||||
|
.created_at
|
||||||
|
.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
updated_at: o
|
||||||
|
.updated_at
|
||||||
|
.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
.unwrap_or_default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,12 +100,73 @@ pub(crate) fn parse_date(s: &str) -> Result<time::Date, StatusCode> {
|
|||||||
time::Date::parse(s, &fmt).map_err(|_| StatusCode::UNPROCESSABLE_ENTITY)
|
time::Date::parse(s, &fmt).map_err(|_| StatusCode::UNPROCESSABLE_ENTITY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Query parameters for the object list: pagination plus whitelisted sort/order and
|
||||||
|
/// optional visibility/quick-filter. All values are validated/clamped server-side; the
|
||||||
|
/// `sort` token maps onto an enum (never a raw column name) before reaching SQL.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(crate) struct ObjectListParams {
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
pub order: Option<String>,
|
||||||
|
pub visibility: Option<String>,
|
||||||
|
pub q: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectListParams {
|
||||||
|
fn limit(&self) -> i64 {
|
||||||
|
self.limit
|
||||||
|
.unwrap_or(crate::pagination::DEFAULT_LIMIT)
|
||||||
|
.clamp(1, crate::pagination::MAX_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offset(&self) -> i64 {
|
||||||
|
self.offset.unwrap_or(0).max(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sort(&self) -> db::catalog::ObjectSort {
|
||||||
|
use db::catalog::ObjectSort;
|
||||||
|
|
||||||
|
match self.sort.as_deref() {
|
||||||
|
Some("object_name") => ObjectSort::ObjectName,
|
||||||
|
Some("updated_at") => ObjectSort::UpdatedAt,
|
||||||
|
Some("created_at") => ObjectSort::CreatedAt,
|
||||||
|
Some("visibility") => ObjectSort::Visibility,
|
||||||
|
// Unknown or absent → stable default.
|
||||||
|
_ => ObjectSort::ObjectNumber,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descending(&self) -> bool {
|
||||||
|
self.order.as_deref() == Some("desc")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate `visibility` against the domain enum; an unknown value is ignored
|
||||||
|
/// (treated as no filter) so hand-edited URLs degrade gracefully instead of 500ing.
|
||||||
|
fn visibility(&self) -> Option<&str> {
|
||||||
|
self.visibility
|
||||||
|
.as_deref()
|
||||||
|
.filter(|v| Visibility::from_db(v).is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn q(&self) -> Option<&str> {
|
||||||
|
self.q.as_deref().map(str::trim).filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// List objects (paginated, all visibility levels). Requires `ViewInternal`.
|
/// List objects (paginated, all visibility levels). Requires `ViewInternal`.
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get, path = "/api/admin/objects",
|
get, path = "/api/admin/objects",
|
||||||
params(
|
params(
|
||||||
("limit" = Option<i64>, Query, description = "1..=200, default 50"),
|
("limit" = Option<i64>, Query, description = "1..=200, default 50"),
|
||||||
("offset" = Option<i64>, Query, description = "default 0")
|
("offset" = Option<i64>, Query, description = "default 0"),
|
||||||
|
("sort" = Option<String>, Query,
|
||||||
|
description = "object_number | object_name | updated_at | created_at | visibility (default object_number)"),
|
||||||
|
("order" = Option<String>, Query, description = "asc | desc (default asc)"),
|
||||||
|
("visibility" = Option<String>, Query,
|
||||||
|
description = "draft | internal | public — filter; unknown values ignored"),
|
||||||
|
("q" = Option<String>, Query,
|
||||||
|
description = "quick filter: ILIKE match on object_number or object_name")
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = AdminObjectPage),
|
(status = 200, body = AdminObjectPage),
|
||||||
@@ -99,15 +177,22 @@ pub(crate) fn parse_date(s: &str) -> Result<time::Date, StatusCode> {
|
|||||||
pub(crate) async fn list_objects(
|
pub(crate) async fn list_objects(
|
||||||
_auth: Authorized<ViewInternal>,
|
_auth: Authorized<ViewInternal>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(page): Query<Pagination>,
|
Query(params): Query<ObjectListParams>,
|
||||||
) -> Result<Json<AdminObjectPage>, StatusCode> {
|
) -> Result<Json<AdminObjectPage>, StatusCode> {
|
||||||
let (limit, offset) = (page.limit(), page.offset());
|
let (limit, offset) = (params.limit(), params.offset());
|
||||||
|
|
||||||
let objects = db::catalog::list_objects_paged(state.db.pool(), limit, offset)
|
let query = db::catalog::ObjectQuery {
|
||||||
|
sort: params.sort(),
|
||||||
|
descending: params.descending(),
|
||||||
|
visibility: params.visibility(),
|
||||||
|
q: params.q(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let objects = db::catalog::list_objects_query(state.db.pool(), &query, limit, offset)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
let total = db::catalog::count_objects(state.db.pool())
|
let total = db::catalog::count_objects_query(state.db.pool(), query.visibility, query.q)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
@@ -158,7 +243,6 @@ pub(crate) struct ObjectCreateRequest {
|
|||||||
pub recorder: Option<String>,
|
pub recorder: Option<String>,
|
||||||
pub recording_date: Option<String>,
|
pub recording_date: Option<String>,
|
||||||
/// "draft" | "internal" (public is rejected — publish via the visibility endpoint).
|
/// "draft" | "internal" (public is rejected — publish via the visibility endpoint).
|
||||||
#[schema(value_type = String)]
|
|
||||||
pub visibility: Visibility,
|
pub visibility: Visibility,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,12 +440,31 @@ pub(crate) async fn delete_object(
|
|||||||
pub(crate) struct FieldDefinitionView {
|
pub(crate) struct FieldDefinitionView {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
/// "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority".
|
/// "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority".
|
||||||
|
#[schema(value_type = domain::DataType)]
|
||||||
|
pub data_type: String,
|
||||||
|
pub vocabulary_id: Option<String>,
|
||||||
|
#[schema(value_type = Option<domain::AuthorityKind>)]
|
||||||
|
pub authority_kind: Option<String>,
|
||||||
|
pub required: bool,
|
||||||
|
pub group: Option<String>,
|
||||||
|
pub labels: Vec<LabelView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub(crate) struct NewFieldDefinitionRequest {
|
||||||
|
pub key: String,
|
||||||
|
/// text | localized_text | integer | date | boolean | term | authority
|
||||||
pub data_type: String,
|
pub data_type: String,
|
||||||
pub vocabulary_id: Option<String>,
|
pub vocabulary_id: Option<String>,
|
||||||
pub authority_kind: Option<String>,
|
pub authority_kind: Option<String>,
|
||||||
pub required: bool,
|
pub required: bool,
|
||||||
pub group: Option<String>,
|
pub group: Option<String>,
|
||||||
pub labels: Vec<LabelView>,
|
pub labels: Vec<LabelInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||||
|
pub(crate) struct CreatedField {
|
||||||
|
pub key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all field definitions. Requires `ViewInternal`.
|
/// List all field definitions. Requires `ViewInternal`.
|
||||||
@@ -407,6 +510,222 @@ pub(crate) async fn list_field_definitions(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a field definition. Requires `EditCatalogue`. All type/binding consistency
|
||||||
|
/// (term needs a vocabulary, authority takes no vocabulary, scalars take no binding) is
|
||||||
|
/// validated by `FieldType::from_parts`, which returns `None` for any bad combination.
|
||||||
|
#[utoipa::path(
|
||||||
|
post, path = "/api/admin/field-definitions",
|
||||||
|
request_body = NewFieldDefinitionRequest,
|
||||||
|
responses(
|
||||||
|
(status = 201, body = CreatedField),
|
||||||
|
(status = 400, description = "Malformed vocabulary_id or authority_kind"),
|
||||||
|
(status = 401),
|
||||||
|
(status = 403),
|
||||||
|
(status = 409, description = "Duplicate key"),
|
||||||
|
(status = 422, description = "Inconsistent type/binding")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn create_field_definition(
|
||||||
|
_auth: Authorized<EditCatalogue>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<NewFieldDefinitionRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<CreatedField>), StatusCode> {
|
||||||
|
let vocabulary_id = match req.vocabulary_id.as_deref() {
|
||||||
|
None | Some("") => None,
|
||||||
|
Some(s) => Some(
|
||||||
|
s.parse::<VocabularyId>()
|
||||||
|
.map_err(|_| StatusCode::BAD_REQUEST)?,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let authority_kind = match req.authority_kind.as_deref() {
|
||||||
|
None | Some("") => None,
|
||||||
|
Some(s) => Some(AuthorityKind::from_db(s).ok_or(StatusCode::BAD_REQUEST)?),
|
||||||
|
};
|
||||||
|
|
||||||
|
let field_type = FieldType::from_parts(&req.data_type, vocabulary_id, authority_kind)
|
||||||
|
.ok_or(StatusCode::UNPROCESSABLE_ENTITY)?;
|
||||||
|
|
||||||
|
let new = NewFieldDefinition {
|
||||||
|
key: req.key,
|
||||||
|
field_type,
|
||||||
|
required: req.required,
|
||||||
|
group_key: req.group,
|
||||||
|
labels: req
|
||||||
|
.labels
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| LocalizedLabel {
|
||||||
|
lang: l.lang,
|
||||||
|
label: l.label,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tx = state
|
||||||
|
.db
|
||||||
|
.pool()
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
match db::fields::create_field_definition(&mut tx, &new).await {
|
||||||
|
Ok(_) => {
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(CreatedField { key: new.key })))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
match err.as_database_error().and_then(|e| e.code()).as_deref() {
|
||||||
|
// Duplicate `key` violates the unique index.
|
||||||
|
Some("23505") => Err(StatusCode::CONFLICT),
|
||||||
|
// Referenced vocabulary doesn't exist — client error, not server fault.
|
||||||
|
Some("23503") => Err(StatusCode::UNPROCESSABLE_ENTITY),
|
||||||
|
// CHECK constraint violated (e.g. empty key) — client error.
|
||||||
|
Some("23514") => Err(StatusCode::UNPROCESSABLE_ENTITY),
|
||||||
|
_ => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fields that may be changed on an existing field definition. `key`, `data_type`, and
|
||||||
|
/// binding are immutable and intentionally absent from this request.
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub(crate) struct UpdateFieldDefinitionRequest {
|
||||||
|
pub required: bool,
|
||||||
|
pub group: Option<String>,
|
||||||
|
pub labels: Vec<LabelInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a field definition's mutable attributes (labels, group, required).
|
||||||
|
/// `key`, `data_type`, and binding are immutable. Requires `EditCatalogue`.
|
||||||
|
#[utoipa::path(
|
||||||
|
patch, path = "/api/admin/field-definitions/{key}",
|
||||||
|
request_body = UpdateFieldDefinitionRequest,
|
||||||
|
params(("key" = String, Path, description = "Field definition key")),
|
||||||
|
responses(
|
||||||
|
(status = 204),
|
||||||
|
(status = 401),
|
||||||
|
(status = 403),
|
||||||
|
(status = 404),
|
||||||
|
(status = 422, description = "CHECK constraint violated (e.g. empty label)")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn update_field_definition(
|
||||||
|
auth: Authorized<EditCatalogue>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(key): Path<String>,
|
||||||
|
Json(req): Json<UpdateFieldDefinitionRequest>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
let labels: Vec<LocalizedLabel> = req
|
||||||
|
.labels
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| LocalizedLabel {
|
||||||
|
lang: l.lang,
|
||||||
|
label: l.label,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut tx = state
|
||||||
|
.db
|
||||||
|
.pool()
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let result = db::fields::update_field_definition(
|
||||||
|
&mut tx,
|
||||||
|
actor(&auth.user),
|
||||||
|
&key,
|
||||||
|
req.required,
|
||||||
|
req.group.as_deref(),
|
||||||
|
&labels,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(true) => {
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
Err(StatusCode::NOT_FOUND)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
match err.as_database_error().and_then(|e| e.code()).as_deref() {
|
||||||
|
Some("23514") => Err(StatusCode::UNPROCESSABLE_ENTITY),
|
||||||
|
_ => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a field definition. Blocked (409) when catalogue objects store a value under
|
||||||
|
/// this key. Requires `EditCatalogue`.
|
||||||
|
#[utoipa::path(
|
||||||
|
delete, path = "/api/admin/field-definitions/{key}",
|
||||||
|
params(("key" = String, Path, description = "Field definition key")),
|
||||||
|
responses(
|
||||||
|
(status = 204),
|
||||||
|
(status = 401),
|
||||||
|
(status = 403),
|
||||||
|
(status = 404),
|
||||||
|
(status = 409, body = crate::admin_vocab::InUseView,
|
||||||
|
description = "Field is used by catalogue objects")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn delete_field_definition(
|
||||||
|
auth: Authorized<EditCatalogue>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(key): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
use crate::admin_vocab::InUseView;
|
||||||
|
|
||||||
|
let Ok(mut tx) = state.db.pool().begin().await else {
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
match db::fields::delete_field_definition(&mut tx, actor(&auth.user), &key).await {
|
||||||
|
Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
},
|
||||||
|
Ok(db::DeleteOutcome::InUse { count }) => {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
(StatusCode::CONFLICT, Json(InUseView { count })).into_response()
|
||||||
|
}
|
||||||
|
Ok(db::DeleteOutcome::NotFound) => {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
StatusCode::NOT_FOUND.into_response()
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Field-level rejection detail for `set_fields`, so the UI can highlight the field.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct FieldErrorView {
|
||||||
|
/// The flexible-field key that was rejected.
|
||||||
|
pub field: String,
|
||||||
|
/// Machine code: "unknown" | "type_mismatch" | "unresolved".
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Replace an object's flexible-field values (validated against the registry).
|
/// Replace an object's flexible-field values (validated against the registry).
|
||||||
///
|
///
|
||||||
/// **Replace semantics:** the body is the *complete* desired field set. Omitting a key
|
/// **Replace semantics:** the body is the *complete* desired field set. Omitting a key
|
||||||
@@ -422,7 +741,7 @@ pub(crate) async fn list_field_definitions(
|
|||||||
(status = 401),
|
(status = 401),
|
||||||
(status = 403),
|
(status = 403),
|
||||||
(status = 404, description = "Object not found"),
|
(status = 404, description = "Object not found"),
|
||||||
(status = 422, description = "Unknown field, type mismatch, or unresolved reference")
|
(status = 422, body = FieldErrorView, description = "A field was rejected")
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub(crate) async fn set_fields(
|
pub(crate) async fn set_fields(
|
||||||
@@ -430,34 +749,57 @@ pub(crate) async fn set_fields(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(values): Json<serde_json::Map<String, serde_json::Value>>,
|
Json(values): Json<serde_json::Map<String, serde_json::Value>>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> axum::response::Response {
|
||||||
let object_id = id.parse::<ObjectId>().map_err(|_| StatusCode::NOT_FOUND)?;
|
use axum::response::IntoResponse;
|
||||||
|
|
||||||
let mut tx = state
|
let Ok(object_id) = id.parse::<ObjectId>() else {
|
||||||
.db
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
.pool()
|
};
|
||||||
.begin()
|
|
||||||
.await
|
let mut tx = match state.db.pool().begin().await {
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
Ok(tx) => tx,
|
||||||
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
let result =
|
let result =
|
||||||
db::catalog::set_object_fields(&mut tx, actor(&auth.user), object_id, &values).await;
|
db::catalog::set_object_fields(&mut tx, actor(&auth.user), object_id, &values).await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
tx.commit()
|
if tx.commit().await.is_err() {
|
||||||
.await
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
}
|
||||||
|
|
||||||
reindex(&state, object_id).await;
|
reindex(&state, object_id).await;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
StatusCode::NO_CONTENT.into_response()
|
||||||
}
|
}
|
||||||
Err(db::catalog::FieldError::ObjectNotFound) => Err(StatusCode::NOT_FOUND),
|
Err(db::catalog::FieldError::ObjectNotFound) => StatusCode::NOT_FOUND.into_response(),
|
||||||
Err(db::catalog::FieldError::Db(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
Err(db::catalog::FieldError::Db(_)) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
Err(db::catalog::FieldError::UnknownField(_)) => Err(StatusCode::UNPROCESSABLE_ENTITY),
|
Err(db::catalog::FieldError::UnknownField(field)) => (
|
||||||
Err(db::catalog::FieldError::TypeMismatch { .. }) => Err(StatusCode::UNPROCESSABLE_ENTITY),
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
Err(db::catalog::FieldError::Unresolved { .. }) => Err(StatusCode::UNPROCESSABLE_ENTITY),
|
Json(FieldErrorView {
|
||||||
|
field,
|
||||||
|
code: "unknown".to_owned(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(db::catalog::FieldError::TypeMismatch { field, .. }) => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
Json(FieldErrorView {
|
||||||
|
field,
|
||||||
|
code: "type_mismatch".to_owned(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(db::catalog::FieldError::Unresolved { field, .. }) => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
Json(FieldErrorView {
|
||||||
|
field,
|
||||||
|
code: "unresolved".to_owned(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,5 +812,12 @@ pub(crate) fn routes() -> Router<AppState> {
|
|||||||
get(get_object).put(update_object).delete(delete_object),
|
get(get_object).put(update_object).delete(delete_object),
|
||||||
)
|
)
|
||||||
.route("/api/admin/objects/{id}/fields", put(set_fields))
|
.route("/api/admin/objects/{id}/fields", put(set_fields))
|
||||||
.route("/api/admin/field-definitions", get(list_field_definitions))
|
.route(
|
||||||
|
"/api/admin/field-definitions",
|
||||||
|
get(list_field_definitions).post(create_field_definition),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/field-definitions/{key}",
|
||||||
|
axum::routing::patch(update_field_definition).delete(delete_field_definition),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ pub(crate) struct SearchHitView {
|
|||||||
pub object_number: String,
|
pub object_number: String,
|
||||||
pub object_name: String,
|
pub object_name: String,
|
||||||
pub brief_description: Option<String>,
|
pub brief_description: Option<String>,
|
||||||
|
#[schema(value_type = domain::Visibility)]
|
||||||
pub visibility: String,
|
pub visibility: String,
|
||||||
|
pub recording_date: Option<String>,
|
||||||
pub snippet: Option<String>,
|
pub snippet: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ pub(crate) async fn search_objects(
|
|||||||
object_name: h.object_name,
|
object_name: h.object_name,
|
||||||
brief_description: h.brief_description,
|
brief_description: h.brief_description,
|
||||||
visibility: h.visibility,
|
visibility: h.visibility,
|
||||||
|
recording_date: h.recording_date,
|
||||||
snippet: h.snippet,
|
snippet: h.snippet,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ use axum::{
|
|||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
use domain::{LocalizedLabel, NewTerm, VocabularyId};
|
use domain::{AuditActor, LocalizedLabel, NewTerm, TermId, VocabularyId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
@@ -85,11 +86,23 @@ pub(crate) async fn list_vocabularies(
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub(crate) async fn create_vocabulary(
|
pub(crate) async fn create_vocabulary(
|
||||||
_auth: Authorized<EditCatalogue>,
|
auth: Authorized<EditCatalogue>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<NewVocabularyRequest>,
|
Json(req): Json<NewVocabularyRequest>,
|
||||||
) -> Result<(StatusCode, Json<VocabularyView>), StatusCode> {
|
) -> Result<(StatusCode, Json<VocabularyView>), StatusCode> {
|
||||||
let vocab = db::vocab::create_vocabulary(state.db.pool(), &req.key)
|
let mut tx = state
|
||||||
|
.db
|
||||||
|
.pool()
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let vocab =
|
||||||
|
db::vocab::create_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &req.key)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
@@ -156,7 +169,7 @@ pub(crate) async fn list_terms(
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub(crate) async fn add_term(
|
pub(crate) async fn add_term(
|
||||||
_auth: Authorized<EditCatalogue>,
|
auth: Authorized<EditCatalogue>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(req): Json<NewTermRequest>,
|
Json(req): Json<NewTermRequest>,
|
||||||
@@ -185,9 +198,17 @@ pub(crate) async fn add_term(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
let term_id = db::vocab::add_term(&mut tx, &new)
|
let term_id = db::vocab::add_term(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &new)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|err| {
|
||||||
|
// A well-formed id for a missing vocabulary hits the FK constraint (23503).
|
||||||
|
if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23503") {
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
} else {
|
||||||
|
tracing::error!(?err, "adding term");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
tx.commit()
|
tx.commit()
|
||||||
.await
|
.await
|
||||||
@@ -201,14 +222,262 @@ pub(crate) async fn add_term(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 409 body: how many catalogue objects still reference the entity.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct InUseView {
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub(crate) struct UpdateTermRequest {
|
||||||
|
pub external_uri: Option<String>,
|
||||||
|
pub labels: Vec<LabelInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
patch, path = "/api/admin/vocabularies/{id}/terms/{term_id}",
|
||||||
|
request_body = UpdateTermRequest,
|
||||||
|
params(
|
||||||
|
("id" = String, Path, description = "Vocabulary id (UUID)"),
|
||||||
|
("term_id" = String, Path, description = "Term id (UUID)")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 204),
|
||||||
|
(status = 401),
|
||||||
|
(status = 403),
|
||||||
|
(status = 404)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn update_term(
|
||||||
|
auth: Authorized<EditCatalogue>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((id, term_id)): Path<(String, String)>,
|
||||||
|
Json(req): Json<UpdateTermRequest>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
let vocabulary_id = id
|
||||||
|
.parse::<VocabularyId>()
|
||||||
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let term_id = term_id
|
||||||
|
.parse::<TermId>()
|
||||||
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let labels: Vec<LocalizedLabel> = req
|
||||||
|
.labels
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| LocalizedLabel {
|
||||||
|
lang: l.lang,
|
||||||
|
label: l.label,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut tx = state
|
||||||
|
.db
|
||||||
|
.pool()
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let existed = db::vocab::update_term(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::User(auth.user.id.to_uuid()),
|
||||||
|
vocabulary_id,
|
||||||
|
term_id,
|
||||||
|
req.external_uri.as_deref(),
|
||||||
|
&labels,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if existed {
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
} else {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
Err(StatusCode::NOT_FOUND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete, path = "/api/admin/vocabularies/{id}/terms/{term_id}",
|
||||||
|
params(
|
||||||
|
("id" = String, Path, description = "Vocabulary id (UUID)"),
|
||||||
|
("term_id" = String, Path, description = "Term id (UUID)")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 204),
|
||||||
|
(status = 401),
|
||||||
|
(status = 403),
|
||||||
|
(status = 404),
|
||||||
|
(status = 409, body = InUseView, description = "Referenced by catalogue objects")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn delete_term(
|
||||||
|
auth: Authorized<EditCatalogue>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((id, term_id)): Path<(String, String)>,
|
||||||
|
) -> Response {
|
||||||
|
let (Ok(vocab_id), Ok(term_id)) = (id.parse::<VocabularyId>(), term_id.parse::<TermId>())
|
||||||
|
else {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(mut tx) = state.db.pool().begin().await else {
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let outcome = db::vocab::delete_term(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::User(auth.user.id.to_uuid()),
|
||||||
|
vocab_id,
|
||||||
|
term_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
},
|
||||||
|
Ok(db::DeleteOutcome::InUse { count }) => {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
(StatusCode::CONFLICT, Json(InUseView { count })).into_response()
|
||||||
|
}
|
||||||
|
Ok(db::DeleteOutcome::NotFound) => {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
StatusCode::NOT_FOUND.into_response()
|
||||||
|
}
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub(crate) struct RenameVocabularyRequest {
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
patch, path = "/api/admin/vocabularies/{id}",
|
||||||
|
request_body = RenameVocabularyRequest,
|
||||||
|
params(("id" = String, Path, description = "Vocabulary id (UUID)")),
|
||||||
|
responses(
|
||||||
|
(status = 204),
|
||||||
|
(status = 401),
|
||||||
|
(status = 403),
|
||||||
|
(status = 404),
|
||||||
|
(status = 409, description = "Key already in use")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn rename_vocabulary(
|
||||||
|
auth: Authorized<EditCatalogue>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(req): Json<RenameVocabularyRequest>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
let id = id
|
||||||
|
.parse::<VocabularyId>()
|
||||||
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let mut tx = state
|
||||||
|
.db
|
||||||
|
.pool()
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let existed = db::vocab::rename_vocabulary(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::User(auth.user.id.to_uuid()),
|
||||||
|
id,
|
||||||
|
&req.key,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23505") {
|
||||||
|
StatusCode::CONFLICT
|
||||||
|
} else {
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if existed {
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
} else {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
Err(StatusCode::NOT_FOUND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete, path = "/api/admin/vocabularies/{id}",
|
||||||
|
params(("id" = String, Path, description = "Vocabulary id (UUID)")),
|
||||||
|
responses(
|
||||||
|
(status = 204),
|
||||||
|
(status = 401),
|
||||||
|
(status = 403),
|
||||||
|
(status = 404),
|
||||||
|
(status = 409, body = InUseView, description = "Has terms or is bound by a field")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn delete_vocabulary(
|
||||||
|
auth: Authorized<EditCatalogue>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
let Ok(id) = id.parse::<VocabularyId>() else {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(mut tx) = state.db.pool().begin().await else {
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
match db::vocab::delete_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id).await
|
||||||
|
{
|
||||||
|
Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
},
|
||||||
|
Ok(db::DeleteOutcome::InUse { count }) => {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
(StatusCode::CONFLICT, Json(InUseView { count })).into_response()
|
||||||
|
}
|
||||||
|
Ok(db::DeleteOutcome::NotFound) => {
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
StatusCode::NOT_FOUND.into_response()
|
||||||
|
}
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn routes() -> Router<AppState> {
|
pub(crate) fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/api/admin/vocabularies",
|
"/api/admin/vocabularies",
|
||||||
get(list_vocabularies).post(create_vocabulary),
|
get(list_vocabularies).post(create_vocabulary),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/vocabularies/{id}",
|
||||||
|
axum::routing::patch(rename_vocabulary).delete(delete_vocabulary),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/admin/vocabularies/{id}/terms",
|
"/api/admin/vocabularies/{id}/terms",
|
||||||
get(list_terms).post(add_term),
|
get(list_terms).post(add_term),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/vocabularies/{id}/terms/{term_id}",
|
||||||
|
axum::routing::patch(update_term).delete(delete_term),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use axum::{Json, Router, extract::State, routing::get};
|
||||||
|
use serde::Serialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Public, non-sensitive instance configuration the SPA needs before login.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct ConfigView {
|
||||||
|
/// User-facing product name.
|
||||||
|
pub app_name: String,
|
||||||
|
/// Default UI/content language (i18n key, e.g. "sv").
|
||||||
|
pub default_language: String,
|
||||||
|
/// Default display timezone (IANA name). Storage is UTC; this is a display hint.
|
||||||
|
pub default_timezone: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/config", responses((status = 200, body = ConfigView)))]
|
||||||
|
pub(crate) async fn get_config(State(state): State<AppState>) -> Json<ConfigView> {
|
||||||
|
Json(ConfigView {
|
||||||
|
app_name: state.app_name.clone(),
|
||||||
|
default_language: state.default_language.clone(),
|
||||||
|
default_timezone: state.default_timezone.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn routes() -> Router<AppState> {
|
||||||
|
Router::new().route("/api/config", get(get_config))
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ mod admin_authorities;
|
|||||||
mod admin_objects;
|
mod admin_objects;
|
||||||
mod admin_search;
|
mod admin_search;
|
||||||
mod admin_vocab;
|
mod admin_vocab;
|
||||||
|
mod config;
|
||||||
mod health;
|
mod health;
|
||||||
mod openapi;
|
mod openapi;
|
||||||
mod pagination;
|
mod pagination;
|
||||||
@@ -30,6 +31,10 @@ pub struct AppState {
|
|||||||
/// Search client for on-write index sync. `None` disables indexing (search is a
|
/// Search client for on-write index sync. `None` disables indexing (search is a
|
||||||
/// best-effort feature; absent when Meilisearch is not configured).
|
/// best-effort feature; absent when Meilisearch is not configured).
|
||||||
pub search: Option<search::SearchClient>,
|
pub search: Option<search::SearchClient>,
|
||||||
|
/// Instance default UI/content language (from config).
|
||||||
|
pub default_language: String,
|
||||||
|
/// Instance default display timezone, IANA name (from config). Storage stays UTC.
|
||||||
|
pub default_timezone: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort: keep the search index in step with a catalogue write that has already
|
/// Best-effort: keep the search index in step with a catalogue write that has already
|
||||||
@@ -58,6 +63,7 @@ pub fn build_app(state: AppState) -> Router {
|
|||||||
.with_expiry(Expiry::OnInactivity(Duration::hours(8)));
|
.with_expiry(Expiry::OnInactivity(Duration::hours(8)));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.merge(config::routes())
|
||||||
.merge(health::routes())
|
.merge(health::routes())
|
||||||
.merge(openapi::routes())
|
.merge(openapi::routes())
|
||||||
.merge(public::routes())
|
.merge(public::routes())
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ use axum::{Json, Router, extract::State, routing::get};
|
|||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AppState, admin, admin_authorities, admin_objects, admin_search, admin_vocab, health, public,
|
AppState, admin, admin_authorities, admin_objects, admin_search, admin_vocab, config, health,
|
||||||
|
public,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
paths(
|
||||||
|
config::get_config,
|
||||||
health::live,
|
health::live,
|
||||||
health::ready,
|
health::ready,
|
||||||
public::list_objects,
|
public::list_objects,
|
||||||
@@ -23,16 +25,26 @@ use crate::{
|
|||||||
admin_objects::update_object,
|
admin_objects::update_object,
|
||||||
admin_objects::delete_object,
|
admin_objects::delete_object,
|
||||||
admin_objects::list_field_definitions,
|
admin_objects::list_field_definitions,
|
||||||
|
admin_objects::create_field_definition,
|
||||||
|
admin_objects::update_field_definition,
|
||||||
|
admin_objects::delete_field_definition,
|
||||||
admin_objects::set_fields,
|
admin_objects::set_fields,
|
||||||
admin_vocab::list_vocabularies,
|
admin_vocab::list_vocabularies,
|
||||||
admin_vocab::create_vocabulary,
|
admin_vocab::create_vocabulary,
|
||||||
admin_vocab::list_terms,
|
admin_vocab::list_terms,
|
||||||
admin_vocab::add_term,
|
admin_vocab::add_term,
|
||||||
|
admin_vocab::update_term,
|
||||||
|
admin_vocab::delete_term,
|
||||||
|
admin_vocab::rename_vocabulary,
|
||||||
|
admin_vocab::delete_vocabulary,
|
||||||
admin_search::search_objects,
|
admin_search::search_objects,
|
||||||
admin_authorities::list_authorities,
|
admin_authorities::list_authorities,
|
||||||
admin_authorities::create_authority
|
admin_authorities::create_authority,
|
||||||
|
admin_authorities::update_authority,
|
||||||
|
admin_authorities::delete_authority
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
|
config::ConfigView,
|
||||||
health::Live,
|
health::Live,
|
||||||
health::Ready,
|
health::Ready,
|
||||||
public::PublicView,
|
public::PublicView,
|
||||||
@@ -47,16 +59,27 @@ use crate::{
|
|||||||
admin_objects::ObjectUpdateRequest,
|
admin_objects::ObjectUpdateRequest,
|
||||||
admin_objects::CreatedObject,
|
admin_objects::CreatedObject,
|
||||||
admin_objects::FieldDefinitionView,
|
admin_objects::FieldDefinitionView,
|
||||||
|
admin_objects::NewFieldDefinitionRequest,
|
||||||
|
admin_objects::UpdateFieldDefinitionRequest,
|
||||||
|
admin_objects::CreatedField,
|
||||||
|
admin_objects::FieldErrorView,
|
||||||
admin_vocab::VocabularyView,
|
admin_vocab::VocabularyView,
|
||||||
admin_vocab::NewVocabularyRequest,
|
admin_vocab::NewVocabularyRequest,
|
||||||
admin_vocab::NewTermRequest,
|
admin_vocab::NewTermRequest,
|
||||||
admin_vocab::LabelInput,
|
admin_vocab::LabelInput,
|
||||||
admin_vocab::TermView,
|
admin_vocab::TermView,
|
||||||
admin_vocab::CreatedId,
|
admin_vocab::CreatedId,
|
||||||
|
admin_vocab::UpdateTermRequest,
|
||||||
|
admin_vocab::InUseView,
|
||||||
|
admin_vocab::RenameVocabularyRequest,
|
||||||
admin_search::SearchHitView,
|
admin_search::SearchHitView,
|
||||||
admin_search::SearchResultsView,
|
admin_search::SearchResultsView,
|
||||||
admin_authorities::AuthorityView,
|
admin_authorities::AuthorityView,
|
||||||
admin_authorities::NewAuthorityRequest
|
admin_authorities::NewAuthorityRequest,
|
||||||
|
admin_authorities::UpdateAuthorityRequest,
|
||||||
|
domain::Visibility,
|
||||||
|
domain::AuthorityKind,
|
||||||
|
domain::DataType
|
||||||
)),
|
)),
|
||||||
info(title = "Collection Management System", version = "0.0.0")
|
info(title = "Collection Management System", version = "0.0.0")
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -71,11 +71,17 @@ pub(crate) async fn list_objects(
|
|||||||
// public read surface.
|
// public read surface.
|
||||||
let objects = db::catalog::list_public_objects(state.db.pool(), limit, offset)
|
let objects = db::catalog::list_public_objects(state.db.pool(), limit, offset)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|err| {
|
||||||
|
tracing::error!(?err, "listing public objects");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
let total = db::catalog::count_public_objects(state.db.pool())
|
let total = db::catalog::count_public_objects(state.db.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|err| {
|
||||||
|
tracing::error!(?err, "counting public objects");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(PublicObjectPage {
|
Ok(Json(PublicObjectPage {
|
||||||
items: objects.iter().map(PublicView::from_object).collect(),
|
items: objects.iter().map(PublicView::from_object).collect(),
|
||||||
@@ -106,7 +112,10 @@ pub(crate) async fn get_object(
|
|||||||
match db::catalog::public_object_by_id(state.db.pool(), object_id).await {
|
match db::catalog::public_object_by_id(state.db.pool(), object_id).await {
|
||||||
Ok(Some(object)) => Json(PublicView::from_object(&object)).into_response(),
|
Ok(Some(object)) => Json(PublicView::from_object(&object)).into_response(),
|
||||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "fetching public object");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use api::{AppState, build_app, migrate_sessions};
|
use api::{AppState, build_app, migrate_sessions};
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{Request, StatusCode, header};
|
use axum::http::{Request, StatusCode, header};
|
||||||
use db::users;
|
use db::{audit, users};
|
||||||
use domain::{AuditActor, Email, NewUser, Role};
|
use domain::{AuditAction, AuditActor, Email, NewUser, Role};
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,3 +265,721 @@ async fn create_and_list_authorities_by_kind(pool: PgPool) {
|
|||||||
let bad = app2_get(&app, &cookie, "/api/admin/authorities?kind=alien").await;
|
let bad = app2_get(&app, &cookie, "/api/admin/authorities?kind=alien").await;
|
||||||
assert_eq!(bad, StatusCode::UNPROCESSABLE_ENTITY);
|
assert_eq!(bad, StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn add_term_to_missing_vocabulary_is_404(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/admin/vocabularies/00000000-0000-0000-0000-000000000000/terms")
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(r#"{"labels":[{"lang":"en","label":"X"}]}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn creating_a_vocabulary_writes_an_audit_entry(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool.clone()));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/admin/vocabularies")
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(r#"{"key":"audit-test"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
let vocab_id: uuid::Uuid = body["id"].as_str().unwrap().parse().unwrap();
|
||||||
|
|
||||||
|
let history = audit::history_for(&pool, "vocabulary", vocab_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(history.len(), 1);
|
||||||
|
assert_eq!(history[0].action, AuditAction::Created);
|
||||||
|
assert!(
|
||||||
|
matches!(history[0].actor, AuditActor::User(_)),
|
||||||
|
"expected actor to be a user"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send(
|
||||||
|
app: &axum::Router,
|
||||||
|
cookie: &str,
|
||||||
|
method: &str,
|
||||||
|
uri: &str,
|
||||||
|
body: Option<&str>,
|
||||||
|
) -> axum::http::Response<Body> {
|
||||||
|
let mut req = Request::builder()
|
||||||
|
.method(method)
|
||||||
|
.uri(uri)
|
||||||
|
.header(header::COOKIE, cookie);
|
||||||
|
|
||||||
|
if body.is_some() {
|
||||||
|
req = req.header(header::CONTENT_TYPE, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = body
|
||||||
|
.map(|b| Body::from(b.to_owned()))
|
||||||
|
.unwrap_or_else(Body::empty);
|
||||||
|
|
||||||
|
app.clone().oneshot(req.body(body).unwrap()).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn edit_and_delete_term(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let v = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"POST",
|
||||||
|
"/api/admin/vocabularies",
|
||||||
|
Some(r#"{"key":"material"}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let vid: serde_json::Value =
|
||||||
|
serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
let vid = vid["id"].as_str().unwrap().to_owned();
|
||||||
|
|
||||||
|
let t = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"POST",
|
||||||
|
&format!("/api/admin/vocabularies/{vid}/terms"),
|
||||||
|
Some(r#"{"external_uri":null,"labels":[{"lang":"sv","label":"Trä"}]}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let tid: serde_json::Value =
|
||||||
|
serde_json::from_slice(&t.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
let tid = tid["id"].as_str().unwrap().to_owned();
|
||||||
|
|
||||||
|
let patched = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"PATCH",
|
||||||
|
&format!("/api/admin/vocabularies/{vid}/terms/{tid}"),
|
||||||
|
Some(r#"{"external_uri":"https://x","labels":[{"lang":"sv","label":"Träslag"}]}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(patched.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let deleted = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"DELETE",
|
||||||
|
&format!("/api/admin/vocabularies/{vid}/terms/{tid}"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let again = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"DELETE",
|
||||||
|
&format!("/api/admin/vocabularies/{vid}/terms/{tid}"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(again.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn term_edit_delete_requires_auth(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let term_uri = "/api/admin/vocabularies/00000000-0000-0000-0000-000000000000/terms/00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
|
let patch_resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PATCH")
|
||||||
|
.uri(term_uri)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(r#"{"labels":[]}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(patch_resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let delete_resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("DELETE")
|
||||||
|
.uri(term_uri)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn vocabulary_edit_delete_requires_auth(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let vocab_uri = "/api/admin/vocabularies/00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
|
let patch_resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PATCH")
|
||||||
|
.uri(vocab_uri)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(r#"{"key":"x"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(patch_resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let delete_resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("DELETE")
|
||||||
|
.uri(vocab_uri)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn rename_and_delete_vocabulary(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let v = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"POST",
|
||||||
|
"/api/admin/vocabularies",
|
||||||
|
Some(r#"{"key":"old"}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let vid: serde_json::Value =
|
||||||
|
serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
let vid = vid["id"].as_str().unwrap().to_owned();
|
||||||
|
|
||||||
|
let renamed = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"PATCH",
|
||||||
|
&format!("/api/admin/vocabularies/{vid}"),
|
||||||
|
Some(r#"{"key":"new"}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(renamed.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let deleted = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"DELETE",
|
||||||
|
&format!("/api/admin/vocabularies/{vid}"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn delete_vocabulary_with_terms_is_409(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let v = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"POST",
|
||||||
|
"/api/admin/vocabularies",
|
||||||
|
Some(r#"{"key":"material"}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let vid: serde_json::Value =
|
||||||
|
serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
let vid = vid["id"].as_str().unwrap().to_owned();
|
||||||
|
|
||||||
|
send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"POST",
|
||||||
|
&format!("/api/admin/vocabularies/{vid}/terms"),
|
||||||
|
Some(r#"{"external_uri":null,"labels":[{"lang":"sv","label":"Trä"}]}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let blocked = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"DELETE",
|
||||||
|
&format!("/api/admin/vocabularies/{vid}"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(blocked.status(), StatusCode::CONFLICT);
|
||||||
|
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
assert_eq!(body["count"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn delete_authority_referenced_is_409(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
// create an authority
|
||||||
|
let a = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"POST",
|
||||||
|
"/api/admin/authorities",
|
||||||
|
Some(r#"{"kind":"person","external_uri":null,"labels":[{"lang":"sv","label":"Astrid"}]}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(a.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
let aid: serde_json::Value =
|
||||||
|
serde_json::from_slice(&a.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
let aid = aid["id"].as_str().unwrap().to_owned();
|
||||||
|
|
||||||
|
// create an authority-typed field definition
|
||||||
|
send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"POST",
|
||||||
|
"/api/admin/field-definitions",
|
||||||
|
Some(
|
||||||
|
r#"{"key":"maker","data_type":"authority","vocabulary_id":null,"authority_kind":"person","required":false,"group":null,"labels":[{"lang":"sv","label":"Tillverkare"}]}"#,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// create an object
|
||||||
|
let obj = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"POST",
|
||||||
|
"/api/admin/objects",
|
||||||
|
Some(
|
||||||
|
r#"{"object_number":"T-1","object_name":"test object","number_of_objects":1,"visibility":"draft"}"#,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(obj.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
let obj_json: serde_json::Value =
|
||||||
|
serde_json::from_slice(&obj.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
let obj_id = obj_json["id"].as_str().unwrap().to_owned();
|
||||||
|
|
||||||
|
// set the object's maker field to the authority id
|
||||||
|
let fields_body = format!(r#"{{"maker":"{aid}"}}"#);
|
||||||
|
let set = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"PUT",
|
||||||
|
&format!("/api/admin/objects/{obj_id}/fields"),
|
||||||
|
Some(&fields_body),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(set.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
// delete the authority — must be blocked
|
||||||
|
let blocked = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"DELETE",
|
||||||
|
&format!("/api/admin/authorities/{aid}"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(blocked.status(), StatusCode::CONFLICT);
|
||||||
|
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
assert_eq!(body["count"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn edit_and_delete_authority(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let a = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"POST",
|
||||||
|
"/api/admin/authorities",
|
||||||
|
Some(r#"{"kind":"person","external_uri":null,"labels":[{"lang":"sv","label":"Anon"}]}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let aid: serde_json::Value =
|
||||||
|
serde_json::from_slice(&a.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
let aid = aid["id"].as_str().unwrap().to_owned();
|
||||||
|
|
||||||
|
let patched = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"PATCH",
|
||||||
|
&format!("/api/admin/authorities/{aid}"),
|
||||||
|
Some(r#"{"external_uri":"https://viaf.org/1","labels":[{"lang":"sv","label":"Astrid"}]}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(patched.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let deleted = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"DELETE",
|
||||||
|
&format!("/api/admin/authorities/{aid}"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn edit_and_delete_field_definition(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
// create a field definition
|
||||||
|
send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"POST",
|
||||||
|
"/api/admin/field-definitions",
|
||||||
|
Some(r#"{"key":"weight","data_type":"integer","vocabulary_id":null,"authority_kind":null,"required":false,"group":null,"labels":[{"lang":"sv","label":"Vikt"}]}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// PATCH: update required + group + labels
|
||||||
|
let patched = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"PATCH",
|
||||||
|
"/api/admin/field-definitions/weight",
|
||||||
|
Some(r#"{"required":true,"group":"Mått","labels":[{"lang":"sv","label":"Vikt (g)"}]}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(patched.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
// PATCH unknown key → 404
|
||||||
|
let missing = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"PATCH",
|
||||||
|
"/api/admin/field-definitions/nope",
|
||||||
|
Some(r#"{"required":false,"group":null,"labels":[]}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
// DELETE the (unreferenced) field definition
|
||||||
|
let deleted = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"DELETE",
|
||||||
|
"/api/admin/field-definitions/weight",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
// DELETE again → 404
|
||||||
|
let again = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"DELETE",
|
||||||
|
"/api/admin/field-definitions/weight",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(again.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn delete_field_definition_referenced_is_409(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
// create a field definition
|
||||||
|
send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"POST",
|
||||||
|
"/api/admin/field-definitions",
|
||||||
|
Some(r#"{"key":"weight","data_type":"integer","vocabulary_id":null,"authority_kind":null,"required":false,"group":null,"labels":[{"lang":"sv","label":"Vikt"}]}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// create an object and set the field
|
||||||
|
let obj = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"POST",
|
||||||
|
"/api/admin/objects",
|
||||||
|
Some(r#"{"object_number":"T-2","object_name":"test","number_of_objects":1,"visibility":"draft"}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(obj.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
let obj_json: serde_json::Value =
|
||||||
|
serde_json::from_slice(&obj.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
let obj_id = obj_json["id"].as_str().unwrap().to_owned();
|
||||||
|
|
||||||
|
let set = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"PUT",
|
||||||
|
&format!("/api/admin/objects/{obj_id}/fields"),
|
||||||
|
Some(r#"{"weight":42}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(set.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
// delete the field definition — must be blocked
|
||||||
|
let blocked = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"DELETE",
|
||||||
|
"/api/admin/field-definitions/weight",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(blocked.status(), StatusCode::CONFLICT);
|
||||||
|
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
assert_eq!(body["count"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn listed_object_carries_timestamps(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let created = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"POST",
|
||||||
|
"/api/admin/objects",
|
||||||
|
Some(r#"{"object_number":"TS-1","object_name":"clock","number_of_objects":1,"visibility":"draft"}"#),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(created.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
let list = send(&app, &cookie, "GET", "/api/admin/objects", None).await;
|
||||||
|
assert_eq!(list.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&list.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
|
||||||
|
let item = &body["items"][0];
|
||||||
|
let created_at = item["created_at"].as_str().unwrap();
|
||||||
|
let updated_at = item["updated_at"].as_str().unwrap();
|
||||||
|
assert!(!created_at.is_empty(), "created_at must be non-empty");
|
||||||
|
assert!(!updated_at.is_empty(), "updated_at must be non-empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn list_objects_sort_filter_quick_search(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let create = |number: &str, name: &str| {
|
||||||
|
format!(
|
||||||
|
r#"{{"object_number":"{number}","object_name":"{name}","number_of_objects":1,"visibility":"draft"}}"#
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
for (number, name) in [
|
||||||
|
("FOO-1", "foo apple"),
|
||||||
|
("FOO-2", "foo banana"),
|
||||||
|
("BAR-1", "bar cherry"),
|
||||||
|
] {
|
||||||
|
let resp = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"POST",
|
||||||
|
"/api/admin/objects",
|
||||||
|
Some(&create(number, name)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No params → default order is object_number ascending.
|
||||||
|
let default = send(&app, &cookie, "GET", "/api/admin/objects", None).await;
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&default.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
let numbers: Vec<&str> = body["items"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|i| i["object_number"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(numbers, ["BAR-1", "FOO-1", "FOO-2"]);
|
||||||
|
assert_eq!(body["total"], 3);
|
||||||
|
|
||||||
|
// sort=object_name&order=desc&visibility=draft&q=foo
|
||||||
|
let filtered = send(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"GET",
|
||||||
|
"/api/admin/objects?sort=object_name&order=desc&visibility=draft&q=foo",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(filtered.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&filtered.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
|
||||||
|
let names: Vec<&str> = body["items"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|i| i["object_name"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
// Only the two "foo …" objects, name descending.
|
||||||
|
assert_eq!(names, ["foo banana", "foo apple"]);
|
||||||
|
assert_eq!(body["total"], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn field_definition_edit_delete_requires_auth(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
|
||||||
|
let patch_resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PATCH")
|
||||||
|
.uri("/api/admin/field-definitions/weight")
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(r#"{"required":false,"group":null,"labels":[]}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(patch_resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let delete_resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("DELETE")
|
||||||
|
.uri("/api/admin/field-definitions/weight")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
use api::{AppState, build_app, migrate_sessions};
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode, header};
|
||||||
|
use db::users;
|
||||||
|
use domain::{AuditActor, Email, NewUser, Role};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
async fn post_json(
|
||||||
|
app: &axum::Router,
|
||||||
|
cookie: &str,
|
||||||
|
uri: &str,
|
||||||
|
body: &str,
|
||||||
|
) -> axum::http::Response<Body> {
|
||||||
|
app.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(uri)
|
||||||
|
.header(header::COOKIE, cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(body.to_owned()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(pool: PgPool) -> AppState {
|
||||||
|
AppState {
|
||||||
|
db: db::Db::from_pool(pool),
|
||||||
|
app_name: "Test".into(),
|
||||||
|
cookie_secure: false,
|
||||||
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) {
|
||||||
|
let db = db::Db::from_pool(pool.clone());
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
|
||||||
|
users::create_user(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&NewUser {
|
||||||
|
email: Email::parse(email).unwrap(),
|
||||||
|
password_hash: auth::hash_password(password).unwrap(),
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(app: &axum::Router, email: &str, password: &str) -> String {
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/admin/login")
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(format!(
|
||||||
|
r#"{{"email":"{email}","password":"{password}"}}"#
|
||||||
|
)))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
resp.headers()
|
||||||
|
.get(header::SET_COOKIE)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_field(app: &axum::Router, cookie: &str, body: &str) -> axum::http::Response<Body> {
|
||||||
|
post_json(app, cookie, "/api/admin/field-definitions", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn create_requires_auth(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/admin/field-definitions")
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"key":"x","data_type":"text","required":false,"labels":[{"lang":"en","label":"X"}]}"#,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn create_scalar_field_then_lists_it(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let resp = post_field(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
r#"{"key":"height_cm","data_type":"integer","required":true,"group":"Dimensions","labels":[{"lang":"en","label":"Height"},{"lang":"sv","label":"Höjd"}]}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(body["key"], "height_cm");
|
||||||
|
|
||||||
|
let list = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/admin/field-definitions")
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let defs: serde_json::Value =
|
||||||
|
serde_json::from_slice(&list.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
defs.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|d| d["key"] == "height_cm")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn term_without_vocabulary_is_422(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let resp = post_field(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
r#"{"key":"material","data_type":"term","required":false,"labels":[{"lang":"en","label":"Material"}]}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn duplicate_key_is_409(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let body = r#"{"key":"dup","data_type":"text","required":false,"labels":[{"lang":"en","label":"Dup"}]}"#;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
post_field(&app, &cookie, body).await.status(),
|
||||||
|
StatusCode::CREATED
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
post_field(&app, &cookie, body).await.status(),
|
||||||
|
StatusCode::CONFLICT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn create_term_field_with_valid_vocabulary(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let vocab_resp = post_json(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
"/api/admin/vocabularies",
|
||||||
|
r#"{"key":"material"}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(vocab_resp.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
let vocab_body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&vocab_resp.into_body().collect().await.unwrap().to_bytes())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let vocab_id = vocab_body["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
let resp = post_field(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
&format!(
|
||||||
|
r#"{{"key":"material_ref","data_type":"term","vocabulary_id":"{vocab_id}","required":false,"labels":[{{"lang":"en","label":"Material"}}]}}"#
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn term_with_nonexistent_vocabulary_is_422(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let resp = post_field(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
r#"{"key":"bad_ref","data_type":"term","vocabulary_id":"00000000-0000-0000-0000-000000000000","required":false,"labels":[{"lang":"en","label":"Bad"}]}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn create_authority_field(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let resp = post_field(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
r#"{"key":"maker_ref","data_type":"authority","authority_kind":"person","required":false,"labels":[{"lang":"en","label":"Maker"}]}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn empty_key_is_422(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let resp = post_field(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
r#"{"key":"","data_type":"text","required":false,"labels":[{"lang":"en","label":"X"}]}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ fn state(pool: PgPool) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,6 +434,52 @@ async fn set_fields_and_list_field_definitions(pool: PgPool) {
|
|||||||
assert_eq!(bad.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
assert_eq!(bad.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn set_fields_unknown_field_returns_field_detail(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let db = db::Db::from_pool(pool.clone());
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
|
||||||
|
let id = catalog::create_object(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&obj("A-1", "amphora", Visibility::Draft),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PUT")
|
||||||
|
.uri(format!("/api/admin/objects/{id}/fields"))
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(r#"{"definitely_not_a_field":"x"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(body["field"], "definitely_not_a_field");
|
||||||
|
assert_eq!(body["code"], "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "../db/migrations")]
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
async fn create_requires_auth(pool: PgPool) {
|
async fn create_requires_auth(pool: PgPool) {
|
||||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ fn state(pool: PgPool, search: Option<SearchClient>) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search,
|
search,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
use api::{AppState, build_app};
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
fn state(pool: PgPool) -> AppState {
|
||||||
|
AppState {
|
||||||
|
db: db::Db::from_pool(pool),
|
||||||
|
app_name: "Test Museum".into(),
|
||||||
|
cookie_secure: false,
|
||||||
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn config_is_public_and_reflects_state(pool: PgPool) {
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/config")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(body["app_name"], "Test Museum");
|
||||||
|
assert_eq!(body["default_language"], "sv");
|
||||||
|
assert_eq!(body["default_timezone"], "Europe/Stockholm");
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ fn state(pool: PgPool, app_name: &str) -> AppState {
|
|||||||
app_name: app_name.to_string(),
|
app_name: app_name.to_string(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
|
|||||||
app_name: "Test".to_string(),
|
app_name: "Test".to_string(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ fn state(pool: PgPool, search: SearchClient) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: Some(search),
|
search: Some(search),
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+133
-3
@@ -1,16 +1,25 @@
|
|||||||
//! Authority records (person / organisation / place).
|
//! Authority records (person / organisation / place).
|
||||||
|
|
||||||
use domain::{Authority, AuthorityId, AuthorityKind, AuthorityRef, LocalizedLabel, NewAuthority};
|
use domain::{
|
||||||
|
AuditAction, AuditActor, Authority, AuthorityId, AuthorityKind, AuthorityRef, LocalizedLabel,
|
||||||
|
NewAuditEvent, NewAuthority,
|
||||||
|
};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use crate::audit;
|
||||||
|
|
||||||
|
const AUTHORITY_ENTITY_TYPE: &str = "authority";
|
||||||
|
|
||||||
/// Labels aggregated per row as JSON, to read an authority and its labels in one query.
|
/// Labels aggregated per row as JSON, to read an authority and its labels in one query.
|
||||||
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', al.lang, 'label', al.label) \
|
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', al.lang, 'label', al.label) \
|
||||||
ORDER BY al.lang) FILTER (WHERE al.authority_id IS NOT NULL), '[]'::json)";
|
ORDER BY al.lang) FILTER (WHERE al.authority_id IS NOT NULL), '[]'::json)";
|
||||||
|
|
||||||
/// Insert an authority and its labels. Multiple statements — pass a transaction
|
/// Insert an authority and its labels, then record a `created` audit entry. Multiple
|
||||||
/// connection (`&mut *tx`) for atomicity.
|
/// statements — pass a transaction connection (`&mut *tx`) so everything commits
|
||||||
|
/// atomically.
|
||||||
pub async fn create_authority(
|
pub async fn create_authority(
|
||||||
conn: &mut sqlx::PgConnection,
|
conn: &mut sqlx::PgConnection,
|
||||||
|
actor: AuditActor,
|
||||||
new: &NewAuthority,
|
new: &NewAuthority,
|
||||||
) -> Result<AuthorityId, sqlx::Error> {
|
) -> Result<AuthorityId, sqlx::Error> {
|
||||||
let id = AuthorityId::new();
|
let id = AuthorityId::new();
|
||||||
@@ -31,6 +40,18 @@ pub async fn create_authority(
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit::record(
|
||||||
|
&mut *conn,
|
||||||
|
&NewAuditEvent {
|
||||||
|
actor,
|
||||||
|
action: AuditAction::Created,
|
||||||
|
entity_type: AUTHORITY_ENTITY_TYPE.to_owned(),
|
||||||
|
entity_id: id.to_uuid(),
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +124,115 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update an authority's `external_uri` and labels (full replace), recording an
|
||||||
|
/// `updated` audit entry. Returns `false` if no such authority. `kind` is immutable.
|
||||||
|
pub async fn update_authority(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
actor: AuditActor,
|
||||||
|
id: AuthorityId,
|
||||||
|
external_uri: Option<&str>,
|
||||||
|
labels: &[LocalizedLabel],
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let updated = sqlx::query("UPDATE authority SET external_uri = $2 WHERE id = $1")
|
||||||
|
.bind(id.to_uuid())
|
||||||
|
.bind(external_uri)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if updated == 0 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM authority_label WHERE authority_id = $1")
|
||||||
|
.bind(id.to_uuid())
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for label in labels {
|
||||||
|
sqlx::query("INSERT INTO authority_label (authority_id, lang, label) VALUES ($1, $2, $3)")
|
||||||
|
.bind(id.to_uuid())
|
||||||
|
.bind(&label.lang)
|
||||||
|
.bind(&label.label)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
audit::record(
|
||||||
|
&mut *conn,
|
||||||
|
&NewAuditEvent {
|
||||||
|
actor,
|
||||||
|
action: AuditAction::Updated,
|
||||||
|
entity_type: AUTHORITY_ENTITY_TYPE.to_owned(),
|
||||||
|
entity_id: id.to_uuid(),
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count catalogue objects referencing `id` through an `authority`-typed field.
|
||||||
|
pub async fn count_objects_referencing_authority<'e, E>(
|
||||||
|
executor: E,
|
||||||
|
id: AuthorityId,
|
||||||
|
) -> Result<i64, sqlx::Error>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'e>,
|
||||||
|
{
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"SELECT count(*) FROM object o WHERE EXISTS ( \
|
||||||
|
SELECT 1 FROM field_definition fd \
|
||||||
|
WHERE fd.data_type = 'authority' AND o.fields ->> fd.key = $1 )",
|
||||||
|
)
|
||||||
|
.bind(id.to_string())
|
||||||
|
.fetch_one(executor)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an authority (labels cascade) unless catalogue objects reference it,
|
||||||
|
/// recording a `deleted` audit entry.
|
||||||
|
pub async fn delete_authority(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
actor: AuditActor,
|
||||||
|
id: AuthorityId,
|
||||||
|
) -> Result<crate::DeleteOutcome, sqlx::Error> {
|
||||||
|
let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM authority WHERE id = $1")
|
||||||
|
.bind(id.to_uuid())
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if exists.is_none() {
|
||||||
|
return Ok(crate::DeleteOutcome::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = count_objects_referencing_authority(&mut *conn, id).await?;
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
return Ok(crate::DeleteOutcome::InUse { count });
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM authority WHERE id = $1")
|
||||||
|
.bind(id.to_uuid())
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit::record(
|
||||||
|
&mut *conn,
|
||||||
|
&NewAuditEvent {
|
||||||
|
actor,
|
||||||
|
action: AuditAction::Deleted,
|
||||||
|
entity_type: AUTHORITY_ENTITY_TYPE.to_owned(),
|
||||||
|
entity_id: id.to_uuid(),
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(crate::DeleteOutcome::Deleted)
|
||||||
|
}
|
||||||
|
|
||||||
fn map_authority(row: sqlx::postgres::PgRow) -> Result<Authority, sqlx::Error> {
|
fn map_authority(row: sqlx::postgres::PgRow) -> Result<Authority, sqlx::Error> {
|
||||||
let kind_str: String = row.try_get("kind")?;
|
let kind_str: String = row.try_get("kind")?;
|
||||||
let kind = AuthorityKind::from_db(&kind_str)
|
let kind = AuthorityKind::from_db(&kind_str)
|
||||||
|
|||||||
+107
-23
@@ -96,37 +96,121 @@ where
|
|||||||
rows.into_iter().map(map_object).collect()
|
rows.into_iter().map(map_object).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List objects (all visibility levels) ordered by object number, with paging.
|
/// Whitelisted, injection-safe sort columns for the object list. The client never
|
||||||
pub async fn list_objects_paged<'e, E>(
|
/// supplies a column name directly — the API layer maps an opaque token onto a variant,
|
||||||
executor: E,
|
/// and only [`ObjectSort::column`] (returning a `'static str`) reaches the SQL string.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum ObjectSort {
|
||||||
|
ObjectNumber,
|
||||||
|
ObjectName,
|
||||||
|
UpdatedAt,
|
||||||
|
CreatedAt,
|
||||||
|
Visibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectSort {
|
||||||
|
fn column(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ObjectSort::ObjectNumber => "object_number",
|
||||||
|
ObjectSort::ObjectName => "object_name",
|
||||||
|
ObjectSort::UpdatedAt => "updated_at",
|
||||||
|
ObjectSort::CreatedAt => "created_at",
|
||||||
|
ObjectSort::Visibility => "visibility",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters + ordering for a paged object query. `visibility`/`q` are optional;
|
||||||
|
/// both are bound as parameters, never interpolated into the SQL string.
|
||||||
|
pub struct ObjectQuery<'a> {
|
||||||
|
pub sort: ObjectSort,
|
||||||
|
pub descending: bool,
|
||||||
|
pub visibility: Option<&'a str>,
|
||||||
|
pub q: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the optional `WHERE` clause and its ordered bind values from the filters.
|
||||||
|
/// Each clause references a positional placeholder (`$1`, `$2`, …) matching the order
|
||||||
|
/// the returned `binds` are applied; the client's strings only ever arrive as binds.
|
||||||
|
fn where_clause(visibility: Option<&str>, q: Option<&str>) -> (String, Vec<String>) {
|
||||||
|
let mut clauses = Vec::new();
|
||||||
|
let mut binds = Vec::new();
|
||||||
|
|
||||||
|
if let Some(v) = visibility {
|
||||||
|
binds.push(v.to_owned());
|
||||||
|
|
||||||
|
clauses.push(format!("visibility = ${}", binds.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(term) = q {
|
||||||
|
binds.push(format!("%{term}%"));
|
||||||
|
|
||||||
|
let p = binds.len();
|
||||||
|
|
||||||
|
clauses.push(format!(
|
||||||
|
"(object_number ILIKE ${p} OR object_name ILIKE ${p})"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = if clauses.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" WHERE {}", clauses.join(" AND "))
|
||||||
|
};
|
||||||
|
|
||||||
|
(sql, binds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List objects (all visibility levels) with whitelisted sort, optional visibility/quick
|
||||||
|
/// filters, and paging. Ordering uses [`ObjectSort::column`] (a `'static str`) plus a
|
||||||
|
/// stable secondary key, so no client-controlled string ever reaches the SQL text.
|
||||||
|
pub async fn list_objects_query(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
query: &ObjectQuery<'_>,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<CatalogueObject>, sqlx::Error>
|
) -> Result<Vec<CatalogueObject>, sqlx::Error> {
|
||||||
where
|
let (where_sql, binds) = where_clause(query.visibility, query.q);
|
||||||
E: sqlx::PgExecutor<'e>,
|
|
||||||
{
|
|
||||||
let sql =
|
|
||||||
format!("SELECT {OBJECT_COLUMNS} FROM object ORDER BY object_number LIMIT $1 OFFSET $2");
|
|
||||||
|
|
||||||
let rows = sqlx::query(&sql)
|
let dir = if query.descending { "DESC" } else { "ASC" };
|
||||||
.bind(limit)
|
|
||||||
.bind(offset)
|
// Secondary key keeps ordering stable when the primary sort has ties.
|
||||||
.fetch_all(executor)
|
let sql = format!(
|
||||||
.await?;
|
"SELECT {OBJECT_COLUMNS} FROM object{where_sql} \
|
||||||
|
ORDER BY {} {dir}, object_number ASC LIMIT ${} OFFSET ${}",
|
||||||
|
query.sort.column(),
|
||||||
|
binds.len() + 1,
|
||||||
|
binds.len() + 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut sql_query = sqlx::query(&sql);
|
||||||
|
|
||||||
|
for bind in &binds {
|
||||||
|
sql_query = sql_query.bind(bind);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sql_query.bind(limit).bind(offset).fetch_all(pool).await?;
|
||||||
|
|
||||||
rows.into_iter().map(map_object).collect()
|
rows.into_iter().map(map_object).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count all objects (for pagination totals).
|
/// Count objects matching the optional visibility/quick filters (for pagination totals).
|
||||||
pub async fn count_objects<'e, E>(executor: E) -> Result<i64, sqlx::Error>
|
pub async fn count_objects_query(
|
||||||
where
|
pool: &sqlx::PgPool,
|
||||||
E: sqlx::PgExecutor<'e>,
|
visibility: Option<&str>,
|
||||||
{
|
q: Option<&str>,
|
||||||
let row = sqlx::query("SELECT count(*) AS n FROM object")
|
) -> Result<i64, sqlx::Error> {
|
||||||
.fetch_one(executor)
|
let (where_sql, binds) = where_clause(visibility, q);
|
||||||
.await?;
|
|
||||||
|
|
||||||
row.try_get("n")
|
let sql = format!("SELECT count(*) AS n FROM object{where_sql}");
|
||||||
|
|
||||||
|
let mut sql_query = sqlx::query(&sql);
|
||||||
|
|
||||||
|
for bind in &binds {
|
||||||
|
sql_query = sql_query.bind(bind);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql_query.fetch_one(pool).await?.try_get("n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch one **public** object by id. Returns `None` if the object is missing **or**
|
/// Fetch one **public** object by id. Returns `None` if the object is missing **or**
|
||||||
|
|||||||
+118
-2
@@ -1,11 +1,15 @@
|
|||||||
//! Registry of flexible field definitions.
|
//! Registry of flexible field definitions.
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType, LocalizedLabel,
|
AuditAction, AuditActor, AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType,
|
||||||
NewFieldDefinition, VocabularyId,
|
LocalizedLabel, NewAuditEvent, NewFieldDefinition, VocabularyId,
|
||||||
};
|
};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use crate::audit;
|
||||||
|
|
||||||
|
const FIELD_DEFINITION_ENTITY_TYPE: &str = "field_definition";
|
||||||
|
|
||||||
/// Labels aggregated per row as JSON, to read a definition and its labels in one query.
|
/// Labels aggregated per row as JSON, to read a definition and its labels in one query.
|
||||||
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', fdl.lang, 'label', fdl.label) \
|
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', fdl.lang, 'label', fdl.label) \
|
||||||
ORDER BY fdl.lang) FILTER (WHERE fdl.field_definition_id IS NOT NULL), '[]'::json)";
|
ORDER BY fdl.lang) FILTER (WHERE fdl.field_definition_id IS NOT NULL), '[]'::json)";
|
||||||
@@ -121,3 +125,115 @@ fn map_field_definition(row: sqlx::postgres::PgRow) -> Result<FieldDefinition, s
|
|||||||
labels: labels.0,
|
labels: labels.0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update a field definition's mutable attributes (`required`, `group_key`, labels);
|
||||||
|
/// `key`, `data_type`, and binding are immutable and untouched. Records an `updated`
|
||||||
|
/// audit entry. Returns `false` if no such key. Pass a transaction connection.
|
||||||
|
pub async fn update_field_definition(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
actor: AuditActor,
|
||||||
|
key: &str,
|
||||||
|
required: bool,
|
||||||
|
group_key: Option<&str>,
|
||||||
|
labels: &[LocalizedLabel],
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let id: Option<uuid::Uuid> =
|
||||||
|
sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1")
|
||||||
|
.bind(key)
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(id) = id else { return Ok(false) };
|
||||||
|
|
||||||
|
sqlx::query("UPDATE field_definition SET required = $2, group_key = $3 WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.bind(required)
|
||||||
|
.bind(group_key)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM field_definition_label WHERE field_definition_id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for label in labels {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO field_definition_label (field_definition_id, lang, label) \
|
||||||
|
VALUES ($1, $2, $3)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&label.lang)
|
||||||
|
.bind(&label.label)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
audit::record(
|
||||||
|
&mut *conn,
|
||||||
|
&NewAuditEvent {
|
||||||
|
actor,
|
||||||
|
action: AuditAction::Updated,
|
||||||
|
entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(),
|
||||||
|
entity_id: id,
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count catalogue objects that store a value under field `key`.
|
||||||
|
pub async fn count_objects_using_field<'e, E>(executor: E, key: &str) -> Result<i64, sqlx::Error>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'e>,
|
||||||
|
{
|
||||||
|
sqlx::query_scalar("SELECT count(*) FROM object WHERE jsonb_exists(fields, $1)")
|
||||||
|
.bind(key)
|
||||||
|
.fetch_one(executor)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a field definition (labels cascade) unless catalogue objects use its key,
|
||||||
|
/// recording a `deleted` audit entry. Pass a transaction connection.
|
||||||
|
pub async fn delete_field_definition(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
actor: AuditActor,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<crate::DeleteOutcome, sqlx::Error> {
|
||||||
|
let id: Option<uuid::Uuid> =
|
||||||
|
sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1")
|
||||||
|
.bind(key)
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(id) = id else {
|
||||||
|
return Ok(crate::DeleteOutcome::NotFound);
|
||||||
|
};
|
||||||
|
|
||||||
|
let count = count_objects_using_field(&mut *conn, key).await?;
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
return Ok(crate::DeleteOutcome::InUse { count });
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM field_definition WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit::record(
|
||||||
|
&mut *conn,
|
||||||
|
&NewAuditEvent {
|
||||||
|
actor,
|
||||||
|
action: AuditAction::Deleted,
|
||||||
|
entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(),
|
||||||
|
entity_id: id,
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(crate::DeleteOutcome::Deleted)
|
||||||
|
}
|
||||||
|
|||||||
+15
-3
@@ -10,6 +10,17 @@ pub mod vocab;
|
|||||||
|
|
||||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||||
|
|
||||||
|
/// Result of a delete that catalogue-object references may block.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum DeleteOutcome {
|
||||||
|
/// The row was deleted.
|
||||||
|
Deleted,
|
||||||
|
/// Refused: `count` catalogue objects still reference it.
|
||||||
|
InUse { count: i64 },
|
||||||
|
/// The row did not exist.
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
/// A handle to the organization's PostgreSQL database.
|
/// A handle to the organization's PostgreSQL database.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Db {
|
pub struct Db {
|
||||||
@@ -17,10 +28,11 @@ pub struct Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
/// Connect to the database at `database_url`, opening a connection pool.
|
/// Connect to the database at `database_url`, opening a connection pool with at most
|
||||||
pub async fn connect(database_url: &str) -> Result<Self, sqlx::Error> {
|
/// `max_connections` connections.
|
||||||
|
pub async fn connect(database_url: &str, max_connections: u32) -> Result<Self, sqlx::Error> {
|
||||||
let pool = PgPoolOptions::new()
|
let pool = PgPoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(max_connections)
|
||||||
.connect(database_url)
|
.connect(database_url)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
//! populated by the organization or a later import. The inventory-minimum fields
|
//! populated by the organization or a later import. The inventory-minimum fields
|
||||||
//! (object number, name, location, …) live in the typed object core, not here.
|
//! (object number, name, location, …) live in the typed object core, not here.
|
||||||
|
|
||||||
use domain::{AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition, VocabularyId};
|
use domain::{
|
||||||
|
AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition, VocabularyId,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{fields, vocab};
|
use crate::{fields, vocab};
|
||||||
|
|
||||||
@@ -119,7 +121,11 @@ async fn ensure_vocabulary(
|
|||||||
if let Some(existing) = vocab::vocabulary_by_key(&mut *conn, key).await? {
|
if let Some(existing) = vocab::vocabulary_by_key(&mut *conn, key).await? {
|
||||||
Ok(existing.id)
|
Ok(existing.id)
|
||||||
} else {
|
} else {
|
||||||
Ok(vocab::create_vocabulary(&mut *conn, key).await?.id)
|
Ok(
|
||||||
|
vocab::create_vocabulary(&mut *conn, AuditActor::System, key)
|
||||||
|
.await?
|
||||||
|
.id,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+247
-10
@@ -1,25 +1,47 @@
|
|||||||
//! Controlled vocabularies and terms.
|
//! Controlled vocabularies and terms.
|
||||||
|
|
||||||
use domain::{LocalizedLabel, NewTerm, Term, TermId, TermRef, Vocabulary, VocabularyId};
|
use domain::{
|
||||||
|
AuditAction, AuditActor, LocalizedLabel, NewAuditEvent, NewTerm, Term, TermId, TermRef,
|
||||||
|
Vocabulary, VocabularyId,
|
||||||
|
};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use crate::audit;
|
||||||
|
|
||||||
|
const VOCABULARY_ENTITY_TYPE: &str = "vocabulary";
|
||||||
|
const TERM_ENTITY_TYPE: &str = "term";
|
||||||
|
|
||||||
/// Labels aggregated per row as JSON, to read a term and its labels in one query.
|
/// Labels aggregated per row as JSON, to read a term and its labels in one query.
|
||||||
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', tl.lang, 'label', tl.label) \
|
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', tl.lang, 'label', tl.label) \
|
||||||
ORDER BY tl.lang) FILTER (WHERE tl.term_id IS NOT NULL), '[]'::json)";
|
ORDER BY tl.lang) FILTER (WHERE tl.term_id IS NOT NULL), '[]'::json)";
|
||||||
|
|
||||||
/// Create a vocabulary with the given key.
|
/// Create a vocabulary with the given key and record a `created` audit entry, both on
|
||||||
pub async fn create_vocabulary<'e, E>(executor: E, key: &str) -> Result<Vocabulary, sqlx::Error>
|
/// `conn` (pass a transaction connection `&mut *tx` so they commit atomically).
|
||||||
where
|
pub async fn create_vocabulary(
|
||||||
E: sqlx::PgExecutor<'e>,
|
conn: &mut sqlx::PgConnection,
|
||||||
{
|
actor: AuditActor,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<Vocabulary, sqlx::Error> {
|
||||||
let id = VocabularyId::new();
|
let id = VocabularyId::new();
|
||||||
|
|
||||||
sqlx::query("INSERT INTO vocabulary (id, key) VALUES ($1, $2)")
|
sqlx::query("INSERT INTO vocabulary (id, key) VALUES ($1, $2)")
|
||||||
.bind(id.to_uuid())
|
.bind(id.to_uuid())
|
||||||
.bind(key)
|
.bind(key)
|
||||||
.execute(executor)
|
.execute(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
audit::record(
|
||||||
|
&mut *conn,
|
||||||
|
&NewAuditEvent {
|
||||||
|
actor,
|
||||||
|
action: AuditAction::Created,
|
||||||
|
entity_type: VOCABULARY_ENTITY_TYPE.to_owned(),
|
||||||
|
entity_id: id.to_uuid(),
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Vocabulary {
|
Ok(Vocabulary {
|
||||||
id,
|
id,
|
||||||
key: key.to_owned(),
|
key: key.to_owned(),
|
||||||
@@ -54,9 +76,14 @@ where
|
|||||||
row.map(map_vocabulary).transpose()
|
row.map(map_vocabulary).transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a term and its labels. Multiple statements — pass a transaction
|
/// Insert a term and its labels, then record a `created` audit entry. Multiple
|
||||||
/// connection (`&mut *tx`) so the term and its labels commit atomically.
|
/// statements — pass a transaction connection (`&mut *tx`) so everything commits
|
||||||
pub async fn add_term(conn: &mut sqlx::PgConnection, new: &NewTerm) -> Result<TermId, sqlx::Error> {
|
/// atomically.
|
||||||
|
pub async fn add_term(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
actor: AuditActor,
|
||||||
|
new: &NewTerm,
|
||||||
|
) -> Result<TermId, sqlx::Error> {
|
||||||
let id = TermId::new();
|
let id = TermId::new();
|
||||||
|
|
||||||
sqlx::query("INSERT INTO term (id, vocabulary_id, external_uri) VALUES ($1, $2, $3)")
|
sqlx::query("INSERT INTO term (id, vocabulary_id, external_uri) VALUES ($1, $2, $3)")
|
||||||
@@ -75,6 +102,18 @@ pub async fn add_term(conn: &mut sqlx::PgConnection, new: &NewTerm) -> Result<Te
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit::record(
|
||||||
|
&mut *conn,
|
||||||
|
&NewAuditEvent {
|
||||||
|
actor,
|
||||||
|
action: AuditAction::Created,
|
||||||
|
entity_type: TERM_ENTITY_TYPE.to_owned(),
|
||||||
|
entity_id: id.to_uuid(),
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +177,204 @@ where
|
|||||||
Ok(found.map(|_| TermRef::new(term_id, vocabulary_id)))
|
Ok(found.map(|_| TermRef::new(term_id, vocabulary_id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update a term's `external_uri` and labels (full replace), recording an `updated`
|
||||||
|
/// audit entry. Returns `false` if no such term or the term does not belong to
|
||||||
|
/// `vocabulary_id`. Pass a transaction connection.
|
||||||
|
pub async fn update_term(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
actor: AuditActor,
|
||||||
|
vocabulary_id: VocabularyId,
|
||||||
|
term_id: TermId,
|
||||||
|
external_uri: Option<&str>,
|
||||||
|
labels: &[LocalizedLabel],
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let updated =
|
||||||
|
sqlx::query("UPDATE term SET external_uri = $2 WHERE id = $1 AND vocabulary_id = $3")
|
||||||
|
.bind(term_id.to_uuid())
|
||||||
|
.bind(external_uri)
|
||||||
|
.bind(vocabulary_id.to_uuid())
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if updated == 0 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM term_label WHERE term_id = $1")
|
||||||
|
.bind(term_id.to_uuid())
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for label in labels {
|
||||||
|
sqlx::query("INSERT INTO term_label (term_id, lang, label) VALUES ($1, $2, $3)")
|
||||||
|
.bind(term_id.to_uuid())
|
||||||
|
.bind(&label.lang)
|
||||||
|
.bind(&label.label)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
audit::record(
|
||||||
|
&mut *conn,
|
||||||
|
&NewAuditEvent {
|
||||||
|
actor,
|
||||||
|
action: AuditAction::Updated,
|
||||||
|
entity_type: TERM_ENTITY_TYPE.to_owned(),
|
||||||
|
entity_id: term_id.to_uuid(),
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count catalogue objects that reference `term_id` through a `term`-typed field.
|
||||||
|
pub async fn count_objects_referencing_term<'e, E>(
|
||||||
|
executor: E,
|
||||||
|
term_id: TermId,
|
||||||
|
) -> Result<i64, sqlx::Error>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'e>,
|
||||||
|
{
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"SELECT count(*) FROM object o WHERE EXISTS ( \
|
||||||
|
SELECT 1 FROM field_definition fd \
|
||||||
|
WHERE fd.data_type = 'term' AND o.fields ->> fd.key = $1 )",
|
||||||
|
)
|
||||||
|
.bind(term_id.to_string())
|
||||||
|
.fetch_one(executor)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a term (its labels cascade) unless catalogue objects reference it, recording a
|
||||||
|
/// `deleted` audit entry. Pass a transaction connection.
|
||||||
|
pub async fn delete_term(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
actor: AuditActor,
|
||||||
|
vocabulary_id: VocabularyId,
|
||||||
|
term_id: TermId,
|
||||||
|
) -> Result<crate::DeleteOutcome, sqlx::Error> {
|
||||||
|
let exists =
|
||||||
|
sqlx::query_scalar::<_, i32>("SELECT 1 FROM term WHERE id = $1 AND vocabulary_id = $2")
|
||||||
|
.bind(term_id.to_uuid())
|
||||||
|
.bind(vocabulary_id.to_uuid())
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if exists.is_none() {
|
||||||
|
return Ok(crate::DeleteOutcome::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = count_objects_referencing_term(&mut *conn, term_id).await?;
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
return Ok(crate::DeleteOutcome::InUse { count });
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM term WHERE id = $1")
|
||||||
|
.bind(term_id.to_uuid())
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit::record(
|
||||||
|
&mut *conn,
|
||||||
|
&NewAuditEvent {
|
||||||
|
actor,
|
||||||
|
action: AuditAction::Deleted,
|
||||||
|
entity_type: TERM_ENTITY_TYPE.to_owned(),
|
||||||
|
entity_id: term_id.to_uuid(),
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(crate::DeleteOutcome::Deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename a vocabulary's key, recording an `updated` audit entry. Returns `false` if no
|
||||||
|
/// such vocabulary. A unique-key collision surfaces as the underlying sqlx error (23505).
|
||||||
|
pub async fn rename_vocabulary(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
actor: AuditActor,
|
||||||
|
id: VocabularyId,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let updated = sqlx::query("UPDATE vocabulary SET key = $2 WHERE id = $1")
|
||||||
|
.bind(id.to_uuid())
|
||||||
|
.bind(key)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if updated == 0 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
audit::record(
|
||||||
|
&mut *conn,
|
||||||
|
&NewAuditEvent {
|
||||||
|
actor,
|
||||||
|
action: AuditAction::Updated,
|
||||||
|
entity_type: VOCABULARY_ENTITY_TYPE.to_owned(),
|
||||||
|
entity_id: id.to_uuid(),
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a vocabulary unless it still has terms or is bound by a field definition
|
||||||
|
/// (both would otherwise hit the FK `RESTRICT`). Records a `deleted` audit entry.
|
||||||
|
pub async fn delete_vocabulary(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
actor: AuditActor,
|
||||||
|
id: VocabularyId,
|
||||||
|
) -> Result<crate::DeleteOutcome, sqlx::Error> {
|
||||||
|
let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM vocabulary WHERE id = $1")
|
||||||
|
.bind(id.to_uuid())
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if exists.is_none() {
|
||||||
|
return Ok(crate::DeleteOutcome::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
let count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT (SELECT count(*) FROM term WHERE vocabulary_id = $1) \
|
||||||
|
+ (SELECT count(*) FROM field_definition WHERE vocabulary_id = $1)",
|
||||||
|
)
|
||||||
|
.bind(id.to_uuid())
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
return Ok(crate::DeleteOutcome::InUse { count });
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM vocabulary WHERE id = $1")
|
||||||
|
.bind(id.to_uuid())
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit::record(
|
||||||
|
&mut *conn,
|
||||||
|
&NewAuditEvent {
|
||||||
|
actor,
|
||||||
|
action: AuditAction::Deleted,
|
||||||
|
entity_type: VOCABULARY_ENTITY_TYPE.to_owned(),
|
||||||
|
entity_id: id.to_uuid(),
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(crate::DeleteOutcome::Deleted)
|
||||||
|
}
|
||||||
|
|
||||||
fn map_vocabulary(row: sqlx::postgres::PgRow) -> Result<Vocabulary, sqlx::Error> {
|
fn map_vocabulary(row: sqlx::postgres::PgRow) -> Result<Vocabulary, sqlx::Error> {
|
||||||
Ok(Vocabulary {
|
Ok(Vocabulary {
|
||||||
id: VocabularyId::from_uuid(row.try_get("id")?),
|
id: VocabularyId::from_uuid(row.try_get("id")?),
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
use db::{Db, authority};
|
use db::{Db, authority, catalog, fields};
|
||||||
use domain::{AuthorityKind, LocalizedLabel, NewAuthority};
|
use domain::{
|
||||||
|
AuditActor, AuthorityKind, LocalizedLabel, NewAuthority, NewFieldDefinition, Visibility,
|
||||||
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
fn sample_object_input() -> domain::ObjectInput {
|
||||||
|
domain::ObjectInput {
|
||||||
|
object_number: "X.1".into(),
|
||||||
|
object_name: "Test".into(),
|
||||||
|
number_of_objects: 1,
|
||||||
|
brief_description: None,
|
||||||
|
current_location: None,
|
||||||
|
current_owner: None,
|
||||||
|
recorder: None,
|
||||||
|
recording_date: None,
|
||||||
|
visibility: Visibility::Draft,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn new_person(name_sv: &str, name_en: &str) -> NewAuthority {
|
fn new_person(name_sv: &str, name_en: &str) -> NewAuthority {
|
||||||
NewAuthority {
|
NewAuthority {
|
||||||
kind: AuthorityKind::Person,
|
kind: AuthorityKind::Person,
|
||||||
@@ -24,9 +40,13 @@ async fn authority_round_trips_with_labels(pool: PgPool) {
|
|||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let id = authority::create_authority(&mut tx, &new_person("Carl Larsson", "Carl Larsson"))
|
let id = authority::create_authority(
|
||||||
.await
|
&mut tx,
|
||||||
.unwrap();
|
AuditActor::System,
|
||||||
|
&new_person("Carl Larsson", "Carl Larsson"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
tx.commit().await.unwrap();
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
let got = authority::authority_by_id(db.pool(), id)
|
let got = authority::authority_by_id(db.pool(), id)
|
||||||
@@ -47,11 +67,12 @@ async fn list_by_kind_filters(pool: PgPool) {
|
|||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
authority::create_authority(&mut tx, &new_person("A", "A"))
|
authority::create_authority(&mut tx, AuditActor::System, &new_person("A", "A"))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
authority::create_authority(
|
authority::create_authority(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&NewAuthority {
|
&NewAuthority {
|
||||||
kind: AuthorityKind::Place,
|
kind: AuthorityKind::Place,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
@@ -83,7 +104,7 @@ async fn resolve_authority_returns_kind(pool: PgPool) {
|
|||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let id = authority::create_authority(&mut tx, &new_person("X", "X"))
|
let id = authority::create_authority(&mut tx, AuditActor::System, &new_person("X", "X"))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
tx.commit().await.unwrap();
|
tx.commit().await.unwrap();
|
||||||
@@ -108,6 +129,7 @@ async fn authority_with_no_labels_round_trips_empty(pool: PgPool) {
|
|||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let id = authority::create_authority(
|
let id = authority::create_authority(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&NewAuthority {
|
&NewAuthority {
|
||||||
kind: AuthorityKind::Organisation,
|
kind: AuthorityKind::Organisation,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
@@ -125,3 +147,117 @@ async fn authority_with_no_labels_round_trips_empty(pool: PgPool) {
|
|||||||
assert_eq!(got.kind, AuthorityKind::Organisation);
|
assert_eq!(got.kind, AuthorityKind::Organisation);
|
||||||
assert!(got.labels.is_empty());
|
assert!(got.labels.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn update_authority_changes_labels(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let id = authority::create_authority(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&NewAuthority {
|
||||||
|
kind: AuthorityKind::Person,
|
||||||
|
external_uri: None,
|
||||||
|
labels: vec![LocalizedLabel {
|
||||||
|
lang: "sv".into(),
|
||||||
|
label: "Anon".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let existed = authority::update_authority(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
id,
|
||||||
|
Some("https://viaf.org/1"),
|
||||||
|
&[LocalizedLabel {
|
||||||
|
lang: "sv".into(),
|
||||||
|
label: "Astrid".into(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(existed);
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let a = authority::authority_by_id(db.pool(), id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(a.external_uri.as_deref(), Some("https://viaf.org/1"));
|
||||||
|
assert_eq!(a.labels[0].label, "Astrid");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn delete_authority_blocks_when_referenced(pool: PgPool) {
|
||||||
|
use db::DeleteOutcome;
|
||||||
|
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let id = authority::create_authority(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&NewAuthority {
|
||||||
|
kind: AuthorityKind::Person,
|
||||||
|
external_uri: None,
|
||||||
|
labels: vec![LocalizedLabel {
|
||||||
|
lang: "sv".into(),
|
||||||
|
label: "Astrid".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
fields::create_field_definition(
|
||||||
|
&mut tx,
|
||||||
|
&NewFieldDefinition {
|
||||||
|
key: "maker".into(),
|
||||||
|
field_type: domain::FieldType::Authority {
|
||||||
|
kind: Some(AuthorityKind::Person),
|
||||||
|
},
|
||||||
|
required: false,
|
||||||
|
group_key: None,
|
||||||
|
labels: vec![LocalizedLabel {
|
||||||
|
lang: "sv".into(),
|
||||||
|
label: "Tillverkare".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut map = serde_json::Map::new();
|
||||||
|
map.insert("maker".into(), serde_json::Value::String(id.to_string()));
|
||||||
|
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
authority::delete_authority(&mut tx, AuditActor::System, id)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
DeleteOutcome::InUse { count: 1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
authority::delete_authority(&mut tx, AuditActor::System, id)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
DeleteOutcome::Deleted
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
authority::delete_authority(&mut tx, AuditActor::System, id)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
DeleteOutcome::NotFound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,142 @@ async fn list_returns_created_objects(pool: PgPool) {
|
|||||||
assert_eq!(all[1].object_number, "LM-2");
|
assert_eq!(all[1].object_number, "LM-2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn input(number: &str, name: &str, visibility: Visibility) -> ObjectInput {
|
||||||
|
ObjectInput {
|
||||||
|
object_number: number.into(),
|
||||||
|
object_name: name.into(),
|
||||||
|
number_of_objects: 1,
|
||||||
|
brief_description: None,
|
||||||
|
current_location: None,
|
||||||
|
current_owner: None,
|
||||||
|
recorder: None,
|
||||||
|
recording_date: None,
|
||||||
|
visibility,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed(pool: &PgPool, inputs: &[ObjectInput]) {
|
||||||
|
let db = Db::from_pool(pool.clone());
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
|
||||||
|
for it in inputs {
|
||||||
|
catalog::create_object(&mut tx, AuditActor::System, it)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn query_orders_by_name_descending(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool.clone());
|
||||||
|
|
||||||
|
seed(
|
||||||
|
&pool,
|
||||||
|
&[
|
||||||
|
input("LM-1", "alpha", Visibility::Draft),
|
||||||
|
input("LM-2", "gamma", Visibility::Draft),
|
||||||
|
input("LM-3", "beta", Visibility::Draft),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let query = catalog::ObjectQuery {
|
||||||
|
sort: catalog::ObjectSort::ObjectName,
|
||||||
|
descending: true,
|
||||||
|
visibility: None,
|
||||||
|
q: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = catalog::list_objects_query(db.pool(), &query, 50, 0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let names: Vec<&str> = rows.iter().map(|o| o.object_name.as_str()).collect();
|
||||||
|
assert_eq!(names, ["gamma", "beta", "alpha"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn query_filters_by_visibility(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool.clone());
|
||||||
|
|
||||||
|
seed(
|
||||||
|
&pool,
|
||||||
|
&[
|
||||||
|
input("LM-1", "draft one", Visibility::Draft),
|
||||||
|
input("LM-2", "internal one", Visibility::Internal),
|
||||||
|
input("LM-3", "draft two", Visibility::Draft),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let query = catalog::ObjectQuery {
|
||||||
|
sort: catalog::ObjectSort::ObjectNumber,
|
||||||
|
descending: false,
|
||||||
|
visibility: Some("draft"),
|
||||||
|
q: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = catalog::list_objects_query(db.pool(), &query, 50, 0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
assert!(rows.iter().all(|o| o.visibility == Visibility::Draft));
|
||||||
|
|
||||||
|
let total = catalog::count_objects_query(db.pool(), Some("draft"), None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(total, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn query_quick_filter_matches_number_or_name(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool.clone());
|
||||||
|
|
||||||
|
seed(
|
||||||
|
&pool,
|
||||||
|
&[
|
||||||
|
input("RED-1", "scarlet vase", Visibility::Draft),
|
||||||
|
input("BLU-1", "azure bowl", Visibility::Draft),
|
||||||
|
input("LM-9", "red kettle", Visibility::Internal),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Matches the object_number of the first row.
|
||||||
|
let by_number = catalog::ObjectQuery {
|
||||||
|
sort: catalog::ObjectSort::ObjectNumber,
|
||||||
|
descending: false,
|
||||||
|
visibility: None,
|
||||||
|
q: Some("red"),
|
||||||
|
};
|
||||||
|
let rows = catalog::list_objects_query(db.pool(), &by_number, 50, 0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// ILIKE: "RED-1" by number and "red kettle" by name.
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
|
||||||
|
let total = catalog::count_objects_query(db.pool(), None, Some("red"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(total, 2);
|
||||||
|
|
||||||
|
// A term matching only a name.
|
||||||
|
let by_name = catalog::ObjectQuery {
|
||||||
|
sort: catalog::ObjectSort::ObjectNumber,
|
||||||
|
descending: false,
|
||||||
|
visibility: None,
|
||||||
|
q: Some("azure"),
|
||||||
|
};
|
||||||
|
let rows = catalog::list_objects_query(db.pool(), &by_name, 50, 0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].object_number, "BLU-1");
|
||||||
|
}
|
||||||
|
|
||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn object_by_id_missing_is_none(pool: PgPool) {
|
async fn object_by_id_missing_is_none(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
|
|||||||
+141
-3
@@ -1,7 +1,24 @@
|
|||||||
use db::{Db, fields, vocab};
|
use db::{Db, DeleteOutcome, audit, catalog, fields, vocab};
|
||||||
use domain::{AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition};
|
use domain::{
|
||||||
|
AuditAction, AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition,
|
||||||
|
ObjectInput, Visibility,
|
||||||
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
fn sample_object_input() -> ObjectInput {
|
||||||
|
ObjectInput {
|
||||||
|
object_number: "X.1".into(),
|
||||||
|
object_name: "Test".into(),
|
||||||
|
number_of_objects: 1,
|
||||||
|
brief_description: None,
|
||||||
|
current_location: None,
|
||||||
|
current_owner: None,
|
||||||
|
recorder: None,
|
||||||
|
recording_date: None,
|
||||||
|
visibility: Visibility::Draft,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn labels() -> Vec<LocalizedLabel> {
|
fn labels() -> Vec<LocalizedLabel> {
|
||||||
vec![
|
vec![
|
||||||
LocalizedLabel {
|
LocalizedLabel {
|
||||||
@@ -52,9 +69,11 @@ async fn text_field_round_trips(pool: PgPool) {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn term_and_authority_fields_round_trip_their_binding(pool: PgPool) {
|
async fn term_and_authority_fields_round_trip_their_binding(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
let material = vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
fields::create_field_definition(
|
fields::create_field_definition(
|
||||||
@@ -169,3 +188,122 @@ async fn any_authority_scalar_and_zero_labels_round_trip(pool: PgPool) {
|
|||||||
let keys: Vec<&str> = all.iter().map(|d| d.key.as_str()).collect();
|
let keys: Vec<&str> = all.iter().map(|d| d.key.as_str()).collect();
|
||||||
assert_eq!(keys, vec!["donor", "on_display"]);
|
assert_eq!(keys, vec!["donor", "on_display"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn update_field_definition_edits_labels_group_required(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
|
||||||
|
fields::create_field_definition(
|
||||||
|
&mut tx,
|
||||||
|
&NewFieldDefinition {
|
||||||
|
key: "weight".into(),
|
||||||
|
field_type: FieldType::Integer,
|
||||||
|
required: false,
|
||||||
|
group_key: None,
|
||||||
|
labels: vec![LocalizedLabel {
|
||||||
|
lang: "sv".into(),
|
||||||
|
label: "Vikt".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let existed = fields::update_field_definition(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
"weight",
|
||||||
|
true,
|
||||||
|
Some("Mått"),
|
||||||
|
&[LocalizedLabel {
|
||||||
|
lang: "sv".into(),
|
||||||
|
label: "Vikt (g)".into(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(existed);
|
||||||
|
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let def = fields::field_definition_by_key(db.pool(), "weight")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(def.required);
|
||||||
|
assert_eq!(def.group_key.as_deref(), Some("Mått"));
|
||||||
|
assert_eq!(def.labels[0].label, "Vikt (g)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn delete_field_definition_blocks_when_objects_use_it(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
|
||||||
|
fields::create_field_definition(
|
||||||
|
&mut tx,
|
||||||
|
&NewFieldDefinition {
|
||||||
|
key: "weight".into(),
|
||||||
|
field_type: FieldType::Integer,
|
||||||
|
required: false,
|
||||||
|
group_key: None,
|
||||||
|
labels: vec![LocalizedLabel {
|
||||||
|
lang: "sv".into(),
|
||||||
|
label: "Vikt".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let field_def_id = fields::field_definition_by_key(&mut *tx, "weight")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
|
.to_uuid();
|
||||||
|
|
||||||
|
let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut map = serde_json::Map::new();
|
||||||
|
map.insert("weight".into(), serde_json::Value::from(42));
|
||||||
|
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fields::delete_field_definition(&mut tx, AuditActor::System, "weight")
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
DeleteOutcome::InUse { count: 1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fields::delete_field_definition(&mut tx, AuditActor::System, "weight")
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
DeleteOutcome::Deleted
|
||||||
|
);
|
||||||
|
|
||||||
|
let history = audit::history_for(&mut *tx, "field_definition", field_def_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
history.iter().any(|e| e.action == AuditAction::Deleted),
|
||||||
|
"expected a Deleted audit entry for the field_definition"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fields::delete_field_definition(&mut tx, AuditActor::System, "weight")
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
DeleteOutcome::NotFound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,9 +95,12 @@ async fn sets_scalar_fields_and_audits(pool: PgPool) {
|
|||||||
async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) {
|
async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
let id = setup_object(&db).await;
|
let id = setup_object(&db).await;
|
||||||
let material = vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
define(
|
define(
|
||||||
&db,
|
&db,
|
||||||
"material",
|
"material",
|
||||||
@@ -110,6 +113,7 @@ async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) {
|
|||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let wood = vocab::add_term(
|
let wood = vocab::add_term(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&domain::NewTerm {
|
&domain::NewTerm {
|
||||||
vocabulary_id: material.id,
|
vocabulary_id: material.id,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
@@ -180,6 +184,7 @@ async fn authority_field_enforces_kind(pool: PgPool) {
|
|||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let person = db::authority::create_authority(
|
let person = db::authority::create_authority(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&domain::NewAuthority {
|
&domain::NewAuthority {
|
||||||
kind: domain::AuthorityKind::Person,
|
kind: domain::AuthorityKind::Person,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
@@ -190,6 +195,7 @@ async fn authority_field_enforces_kind(pool: PgPool) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let place = db::authority::create_authority(
|
let place = db::authority::create_authority(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&domain::NewAuthority {
|
&domain::NewAuthority {
|
||||||
kind: domain::AuthorityKind::Place,
|
kind: domain::AuthorityKind::Place,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
@@ -219,12 +225,14 @@ async fn authority_field_enforces_kind(pool: PgPool) {
|
|||||||
async fn term_from_wrong_vocabulary_is_rejected(pool: PgPool) {
|
async fn term_from_wrong_vocabulary_is_rejected(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
let id = setup_object(&db).await;
|
let id = setup_object(&db).await;
|
||||||
let material = vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let technique = vocab::create_vocabulary(db.pool(), "technique")
|
let technique = vocab::create_vocabulary(&mut tx, AuditActor::System, "technique")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
define(
|
define(
|
||||||
&db,
|
&db,
|
||||||
"material",
|
"material",
|
||||||
@@ -238,6 +246,7 @@ async fn term_from_wrong_vocabulary_is_rejected(pool: PgPool) {
|
|||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let other = vocab::add_term(
|
let other = vocab::add_term(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&domain::NewTerm {
|
&domain::NewTerm {
|
||||||
vocabulary_id: technique.id,
|
vocabulary_id: technique.id,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
|
|||||||
+258
-9
@@ -1,13 +1,18 @@
|
|||||||
use db::{Db, vocab};
|
use db::{Db, audit, catalog, fields, vocab};
|
||||||
use domain::{LocalizedLabel, NewTerm};
|
use domain::{
|
||||||
|
AuditAction, AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, NewTerm, ObjectInput,
|
||||||
|
Visibility,
|
||||||
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn vocabulary_create_and_lookup(pool: PgPool) {
|
async fn vocabulary_create_and_lookup(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
let v = vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
let found = vocab::vocabulary_by_key(db.pool(), "material")
|
let found = vocab::vocabulary_by_key(db.pool(), "material")
|
||||||
.await
|
.await
|
||||||
@@ -27,13 +32,16 @@ async fn vocabulary_create_and_lookup(pool: PgPool) {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn term_with_multilingual_labels_round_trips(pool: PgPool) {
|
async fn term_with_multilingual_labels_round_trips(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
let v = vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let term_id = vocab::add_term(
|
let term_id = vocab::add_term(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&NewTerm {
|
&NewTerm {
|
||||||
vocabulary_id: v.id,
|
vocabulary_id: v.id,
|
||||||
external_uri: Some("http://vocab.getty.edu/aat/300011914".into()),
|
external_uri: Some("http://vocab.getty.edu/aat/300011914".into()),
|
||||||
@@ -76,13 +84,16 @@ async fn term_with_multilingual_labels_round_trips(pool: PgPool) {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn term_with_no_labels_round_trips_empty(pool: PgPool) {
|
async fn term_with_no_labels_round_trips_empty(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
let v = vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let term_id = vocab::add_term(
|
let term_id = vocab::add_term(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&NewTerm {
|
&NewTerm {
|
||||||
vocabulary_id: v.id,
|
vocabulary_id: v.id,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
@@ -103,10 +114,14 @@ async fn term_with_no_labels_round_trips_empty(pool: PgPool) {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) {
|
async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let err = vocab::create_vocabulary(db.pool(), "material")
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let err = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -118,16 +133,19 @@ async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn resolve_term_checks_vocabulary_membership(pool: PgPool) {
|
async fn resolve_term_checks_vocabulary_membership(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
let material = vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let technique = vocab::create_vocabulary(db.pool(), "technique")
|
let technique = vocab::create_vocabulary(&mut tx, AuditActor::System, "technique")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let term_id = vocab::add_term(
|
let term_id = vocab::add_term(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&NewTerm {
|
&NewTerm {
|
||||||
vocabulary_id: material.id,
|
vocabulary_id: material.id,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
@@ -154,3 +172,234 @@ async fn resolve_term_checks_vocabulary_membership(pool: PgPool) {
|
|||||||
.is_none()
|
.is_none()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sample_object_input() -> ObjectInput {
|
||||||
|
ObjectInput {
|
||||||
|
object_number: "X.1".into(),
|
||||||
|
object_name: "Test".into(),
|
||||||
|
number_of_objects: 1,
|
||||||
|
brief_description: None,
|
||||||
|
current_location: None,
|
||||||
|
current_owner: None,
|
||||||
|
recorder: None,
|
||||||
|
recording_date: None,
|
||||||
|
visibility: Visibility::Draft,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn update_term_changes_labels_and_uri(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let vocab = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let term_id = vocab::add_term(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&NewTerm {
|
||||||
|
vocabulary_id: vocab.id,
|
||||||
|
external_uri: None,
|
||||||
|
labels: vec![LocalizedLabel {
|
||||||
|
lang: "sv".into(),
|
||||||
|
label: "Trä".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let existed = vocab::update_term(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
vocab.id,
|
||||||
|
term_id,
|
||||||
|
Some("https://example.org/wood"),
|
||||||
|
&[LocalizedLabel {
|
||||||
|
lang: "sv".into(),
|
||||||
|
label: "Träslag".into(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(existed);
|
||||||
|
|
||||||
|
let history = audit::history_for(&mut *tx, "term", term_id.to_uuid())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
history.iter().any(|e| e.action == AuditAction::Updated),
|
||||||
|
"expected an Updated audit entry for the term"
|
||||||
|
);
|
||||||
|
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let term = vocab::term_by_id(db.pool(), term_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
term.external_uri.as_deref(),
|
||||||
|
Some("https://example.org/wood")
|
||||||
|
);
|
||||||
|
assert_eq!(term.labels.len(), 1);
|
||||||
|
assert_eq!(term.labels[0].label, "Träslag");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn delete_term_blocks_when_referenced_then_succeeds(pool: PgPool) {
|
||||||
|
use db::DeleteOutcome;
|
||||||
|
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let vocab = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let term_id = vocab::add_term(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&NewTerm {
|
||||||
|
vocabulary_id: vocab.id,
|
||||||
|
external_uri: None,
|
||||||
|
labels: vec![LocalizedLabel {
|
||||||
|
lang: "sv".into(),
|
||||||
|
label: "Trä".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
fields::create_field_definition(
|
||||||
|
&mut tx,
|
||||||
|
&NewFieldDefinition {
|
||||||
|
key: "material".into(),
|
||||||
|
field_type: FieldType::Term {
|
||||||
|
vocabulary_id: vocab.id,
|
||||||
|
},
|
||||||
|
required: false,
|
||||||
|
group_key: None,
|
||||||
|
labels: vec![LocalizedLabel {
|
||||||
|
lang: "sv".into(),
|
||||||
|
label: "Material".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut map = serde_json::Map::new();
|
||||||
|
map.insert(
|
||||||
|
"material".into(),
|
||||||
|
serde_json::Value::String(term_id.to_string()),
|
||||||
|
);
|
||||||
|
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let blocked = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(blocked, DeleteOutcome::InUse { count: 1 });
|
||||||
|
|
||||||
|
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let ok = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ok, DeleteOutcome::Deleted);
|
||||||
|
assert!(
|
||||||
|
vocab::term_by_id(&mut *tx, term_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
let history = audit::history_for(&mut *tx, "term", term_id.to_uuid())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
history.iter().any(|e| e.action == AuditAction::Deleted),
|
||||||
|
"expected a Deleted audit entry for the term"
|
||||||
|
);
|
||||||
|
|
||||||
|
let gone = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(gone, DeleteOutcome::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn rename_vocabulary_changes_key(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "old")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let existed = vocab::rename_vocabulary(&mut tx, AuditActor::System, v.id, "new")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(existed);
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
vocab::vocabulary_by_key(db.pool(), "new")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
vocab::vocabulary_by_key(db.pool(), "old")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn delete_vocabulary_blocks_when_it_has_terms(pool: PgPool) {
|
||||||
|
use db::DeleteOutcome;
|
||||||
|
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
vocab::add_term(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&NewTerm {
|
||||||
|
vocabulary_id: v.id,
|
||||||
|
external_uri: None,
|
||||||
|
labels: vec![LocalizedLabel {
|
||||||
|
lang: "sv".into(),
|
||||||
|
label: "Trä".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let blocked = vocab::delete_vocabulary(&mut tx, AuditActor::System, v.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(blocked, DeleteOutcome::InUse { count: 1 });
|
||||||
|
|
||||||
|
let empty = vocab::create_vocabulary(&mut tx, AuditActor::System, "empty")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
vocab::delete_vocabulary(&mut tx, AuditActor::System, empty.id)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
DeleteOutcome::Deleted
|
||||||
|
);
|
||||||
|
|
||||||
|
let gone = vocab::delete_vocabulary(&mut tx, AuditActor::System, empty.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(gone, DeleteOutcome::NotFound);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ uuid.workspace = true
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
time.workspace = true
|
time.workspace = true
|
||||||
|
utoipa.workspace = true
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ use time::OffsetDateTime;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// What kind of change an audit entry records.
|
/// What kind of change an audit entry records.
|
||||||
|
///
|
||||||
|
/// NOTE: kept in sync by hand with the
|
||||||
|
/// `CHECK (action IN ('created', 'updated', 'deleted'))` constraint in
|
||||||
|
/// `crates/db/migrations/0001_audit_log.sql` — add a variant in both places.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum AuditAction {
|
pub enum AuditAction {
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::{AuthorityId, LocalizedLabel};
|
use crate::{AuthorityId, LocalizedLabel};
|
||||||
|
|
||||||
/// The kind of authority record.
|
/// The kind of authority record.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
///
|
||||||
|
/// NOTE: kept in sync by hand with the
|
||||||
|
/// `CHECK (kind IN ('person', 'organisation', 'place'))` constraint in
|
||||||
|
/// `crates/db/migrations/0002_vocabularies_authorities.sql` — add a variant in both places.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum AuthorityKind {
|
pub enum AuthorityKind {
|
||||||
Person,
|
Person,
|
||||||
|
|||||||
@@ -74,6 +74,23 @@ impl FieldType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The stored `data_type` discriminant of a field definition — mirrors the strings from
|
||||||
|
/// [`FieldType::kind_str`]. Exists so the OpenAPI schema can describe `data_type` as a
|
||||||
|
/// closed string enum (consumed by the typed web client). Keep in sync with `kind_str`.
|
||||||
|
#[derive(
|
||||||
|
Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
|
||||||
|
)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DataType {
|
||||||
|
Text,
|
||||||
|
LocalizedText,
|
||||||
|
Integer,
|
||||||
|
Date,
|
||||||
|
Boolean,
|
||||||
|
Term,
|
||||||
|
Authority,
|
||||||
|
}
|
||||||
|
|
||||||
/// A registered flexible field, with its multilingual display labels.
|
/// A registered flexible field, with its multilingual display labels.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct FieldDefinition {
|
pub struct FieldDefinition {
|
||||||
@@ -152,4 +169,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(FieldType::from_parts("authority", Some(v), None), None);
|
assert_eq!(FieldType::from_parts("authority", Some(v), None), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_type_serde_matches_kind_str() {
|
||||||
|
use serde_json::json;
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(DataType::LocalizedText).unwrap(),
|
||||||
|
json!("localized_text")
|
||||||
|
);
|
||||||
|
assert_eq!(serde_json::to_value(DataType::Text).unwrap(), json!("text"));
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(DataType::Authority).unwrap(),
|
||||||
|
json!("authority")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ mod vocabulary;
|
|||||||
|
|
||||||
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
|
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
|
||||||
pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority};
|
pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority};
|
||||||
pub use field_definition::{FieldDefinition, FieldType, NewFieldDefinition};
|
pub use field_definition::{DataType, FieldDefinition, FieldType, NewFieldDefinition};
|
||||||
pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, UserId, VocabularyId};
|
pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, UserId, VocabularyId};
|
||||||
pub use label::{LocalizedLabel, pick_label};
|
pub use label::{LocalizedLabel, pick_label};
|
||||||
pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility};
|
pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use time::{Date, OffsetDateTime};
|
|||||||
use crate::ObjectId;
|
use crate::ObjectId;
|
||||||
|
|
||||||
/// Publication state of a catalogue record.
|
/// Publication state of a catalogue record.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Visibility {
|
pub enum Visibility {
|
||||||
/// Work in progress; not shown anywhere public.
|
/// Work in progress; not shown anywhere public.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub struct SearchDocument {
|
|||||||
pub brief_description: Option<String>,
|
pub brief_description: Option<String>,
|
||||||
pub current_owner: Option<String>,
|
pub current_owner: Option<String>,
|
||||||
pub recorder: Option<String>,
|
pub recorder: Option<String>,
|
||||||
|
pub recording_date: Option<String>,
|
||||||
/// Filterable: "draft" | "internal" | "public".
|
/// Filterable: "draft" | "internal" | "public".
|
||||||
pub visibility: String,
|
pub visibility: String,
|
||||||
/// Flexible field values flattened to searchable text.
|
/// Flexible field values flattened to searchable text.
|
||||||
@@ -55,6 +56,7 @@ pub struct SearchHit {
|
|||||||
pub object_name: String,
|
pub object_name: String,
|
||||||
pub brief_description: Option<String>,
|
pub brief_description: Option<String>,
|
||||||
pub visibility: String,
|
pub visibility: String,
|
||||||
|
pub recording_date: Option<String>,
|
||||||
pub snippet: Option<String>,
|
pub snippet: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +235,7 @@ impl SearchClient {
|
|||||||
object_name: doc.object_name,
|
object_name: doc.object_name,
|
||||||
brief_description: doc.brief_description,
|
brief_description: doc.brief_description,
|
||||||
visibility: doc.visibility,
|
visibility: doc.visibility,
|
||||||
|
recording_date: doc.recording_date,
|
||||||
snippet,
|
snippet,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -367,6 +370,7 @@ pub async fn build_document(
|
|||||||
brief_description: object.brief_description.clone(),
|
brief_description: object.brief_description.clone(),
|
||||||
current_owner: object.current_owner.clone(),
|
current_owner: object.current_owner.clone(),
|
||||||
recorder: object.recorder.clone(),
|
recorder: object.recorder.clone(),
|
||||||
|
recording_date: object.recording_date.map(|d| d.to_string()),
|
||||||
visibility: object.visibility.as_str().to_owned(),
|
visibility: object.visibility.as_str().to_owned(),
|
||||||
fields_text,
|
fields_text,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,14 +23,15 @@ async fn reindex_resolves_term_labels_and_finds_by_label(pool: PgPool) {
|
|||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
// a material vocabulary with a "wood" term
|
// a material vocabulary with a "wood" term
|
||||||
let material = vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
|
||||||
|
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let mut tx = db.pool().begin().await.unwrap();
|
|
||||||
|
|
||||||
let wood = vocab::add_term(
|
let wood = vocab::add_term(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&NewTerm {
|
&NewTerm {
|
||||||
vocabulary_id: material.id,
|
vocabulary_id: material.id,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ fn doc(id: &str, object_name: &str, fields_text: &[&str]) -> SearchDocument {
|
|||||||
brief_description: None,
|
brief_description: None,
|
||||||
current_owner: None,
|
current_owner: None,
|
||||||
recorder: None,
|
recorder: None,
|
||||||
|
recording_date: None,
|
||||||
visibility: "draft".to_string(),
|
visibility: "draft".to_string(),
|
||||||
fields_text: fields_text.iter().map(|s| s.to_string()).collect(),
|
fields_text: fields_text.iter().map(|s| s.to_string()).collect(),
|
||||||
}
|
}
|
||||||
@@ -66,6 +67,7 @@ async fn search_objects_returns_hits_with_highlight_filter_and_paging() {
|
|||||||
&["cast bronze with green patina"],
|
&["cast bronze with green patina"],
|
||||||
);
|
);
|
||||||
bronze_a.visibility = "public".to_string();
|
bronze_a.visibility = "public".to_string();
|
||||||
|
bronze_a.recording_date = Some("1962-04-03".to_string());
|
||||||
let mut bronze_b = doc(&b.to_string(), "Ceremonial bowl", &["bronze alloy rim"]);
|
let mut bronze_b = doc(&b.to_string(), "Ceremonial bowl", &["bronze alloy rim"]);
|
||||||
bronze_b.visibility = "public".to_string();
|
bronze_b.visibility = "public".to_string();
|
||||||
let mut bronze_c = doc(&c.to_string(), "Door fitting", &["bronze hinge"]);
|
let mut bronze_c = doc(&c.to_string(), "Door fitting", &["bronze hinge"]);
|
||||||
@@ -87,6 +89,7 @@ async fn search_objects_returns_hits_with_highlight_filter_and_paging() {
|
|||||||
"snippet must mark the match"
|
"snippet must mark the match"
|
||||||
);
|
);
|
||||||
assert!(snippet.contains(search::HL_POST));
|
assert!(snippet.contains(search::HL_POST));
|
||||||
|
assert_eq!(hit.recording_date.as_deref(), Some("1962-04-03"));
|
||||||
|
|
||||||
let public = client
|
let public = client
|
||||||
.search_objects("bronze", Some("public"), 0, 20)
|
.search_objects("bronze", Some("public"), 0, 20)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ db = { path = "../db" }
|
|||||||
domain = { path = "../domain" }
|
domain = { path = "../domain" }
|
||||||
search = { path = "../search" }
|
search = { path = "../search" }
|
||||||
rpassword.workspace = true
|
rpassword.workspace = true
|
||||||
|
dotenvy.workspace = true
|
||||||
memory-serve = { workspace = true, optional = true }
|
memory-serve = { workspace = true, optional = true }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
|||||||
@@ -42,4 +42,29 @@ pub struct Config {
|
|||||||
/// Meilisearch index name for catalogue objects.
|
/// Meilisearch index name for catalogue objects.
|
||||||
#[arg(long = "meili-index", env = "MEILI_INDEX", default_value = "objects")]
|
#[arg(long = "meili-index", env = "MEILI_INDEX", default_value = "objects")]
|
||||||
pub meili_index: String,
|
pub meili_index: String,
|
||||||
|
|
||||||
|
/// Maximum size of the PostgreSQL connection pool.
|
||||||
|
#[arg(
|
||||||
|
long = "db-max-connections",
|
||||||
|
env = "DB_MAX_CONNECTIONS",
|
||||||
|
default_value_t = 5
|
||||||
|
)]
|
||||||
|
pub db_max_connections: u32,
|
||||||
|
|
||||||
|
/// Default UI + content-authoring language for this instance (i18n key, e.g. "sv").
|
||||||
|
#[arg(
|
||||||
|
long = "default-language",
|
||||||
|
env = "DEFAULT_LANGUAGE",
|
||||||
|
default_value = "sv"
|
||||||
|
)]
|
||||||
|
pub default_language: String,
|
||||||
|
|
||||||
|
/// Default display timezone (IANA name, e.g. "Europe/Stockholm"). Storage stays UTC;
|
||||||
|
/// this is a display hint surfaced to clients (and, later, server-side renderers).
|
||||||
|
#[arg(
|
||||||
|
long = "default-timezone",
|
||||||
|
env = "DEFAULT_TIMEZONE",
|
||||||
|
default_value = "Europe/Stockholm"
|
||||||
|
)]
|
||||||
|
pub default_timezone: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use tokio::net::TcpListener;
|
|||||||
|
|
||||||
/// Connect dependencies from `config` and serve until shutdown.
|
/// Connect dependencies from `config` and serve until shutdown.
|
||||||
pub async fn run(config: Config) -> anyhow::Result<()> {
|
pub async fn run(config: Config) -> anyhow::Result<()> {
|
||||||
let db = Db::connect(&config.database_url)
|
let db = Db::connect(&config.database_url, config.db_max_connections)
|
||||||
.await
|
.await
|
||||||
.context("connecting to the database")?;
|
.context("connecting to the database")?;
|
||||||
|
|
||||||
@@ -50,9 +50,11 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
db,
|
db,
|
||||||
app_name: config.app_name.clone(),
|
app_name: config.app_name,
|
||||||
cookie_secure: config.cookie_secure,
|
cookie_secure: config.cookie_secure,
|
||||||
search,
|
search,
|
||||||
|
default_language: config.default_language,
|
||||||
|
default_timezone: config.default_timezone,
|
||||||
};
|
};
|
||||||
|
|
||||||
let listener = TcpListener::bind(&config.bind_addr)
|
let listener = TcpListener::bind(&config.bind_addr)
|
||||||
@@ -64,6 +66,34 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
|
|||||||
serve(listener, state).await
|
serve(listener, state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves when the process receives SIGINT (Ctrl-C) or SIGTERM, so the server can
|
||||||
|
/// drain in-flight requests before exiting.
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
tokio::signal::ctrl_c()
|
||||||
|
.await
|
||||||
|
.expect("install Ctrl-C handler");
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let terminate = async {
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("install SIGTERM handler")
|
||||||
|
.recv()
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let terminate = std::future::pending::<()>();
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {},
|
||||||
|
_ = terminate => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("shutdown signal received; draining");
|
||||||
|
}
|
||||||
|
|
||||||
/// Serve the API on an already-bound listener (used by `run` and tests).
|
/// Serve the API on an already-bound listener (used by `run` and tests).
|
||||||
pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> {
|
pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> {
|
||||||
let app = build_app(state);
|
let app = build_app(state);
|
||||||
@@ -72,6 +102,7 @@ pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()>
|
|||||||
let app = app.merge(web_assets::routes());
|
let app = app.merge(web_assets::routes());
|
||||||
|
|
||||||
axum::serve(listener, app)
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
.await
|
.await
|
||||||
.context("running the HTTP server")?;
|
.context("running the HTTP server")?;
|
||||||
|
|
||||||
@@ -86,6 +117,31 @@ pub mod test_support {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One-shot: apply migrations (idempotent), then seed the baseline Spectrum cataloguing
|
||||||
|
/// vocabularies + field definitions. Safe to re-run (the seed is idempotent).
|
||||||
|
pub async fn seed(database_url: &str) -> anyhow::Result<()> {
|
||||||
|
// CLI one-shot: a tiny pool is plenty.
|
||||||
|
let db = Db::connect(database_url, 2)
|
||||||
|
.await
|
||||||
|
.context("connecting to the database")?;
|
||||||
|
|
||||||
|
// Apply migrations first so `server seed` works on a fresh DB without first
|
||||||
|
// starting the server. Migrations are idempotent.
|
||||||
|
db.migrate().await.context("running database migrations")?;
|
||||||
|
|
||||||
|
let mut tx = db.pool().begin().await?;
|
||||||
|
|
||||||
|
db::seed::seed_spectrum_cataloguing(&mut tx)
|
||||||
|
.await
|
||||||
|
.context("seeding Spectrum cataloguing baseline")?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
println!("seeded Spectrum cataloguing baseline (idempotent)");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a user from the CLI (admin bootstrap). Opens its own connection (CLI
|
/// Create a user from the CLI (admin bootstrap). Opens its own connection (CLI
|
||||||
/// one-shot); reads the password from the `BOOTSTRAP_PASSWORD` env var if set,
|
/// one-shot); reads the password from the `BOOTSTRAP_PASSWORD` env var if set,
|
||||||
/// otherwise prompts (hidden input). The plaintext is not zeroized, but it is
|
/// otherwise prompts (hidden input). The plaintext is not zeroized, but it is
|
||||||
@@ -107,7 +163,8 @@ pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow:
|
|||||||
auth::hash_password(&password).map_err(|err| anyhow::anyhow!("hashing password: {err}"))?
|
auth::hash_password(&password).map_err(|err| anyhow::anyhow!("hashing password: {err}"))?
|
||||||
};
|
};
|
||||||
|
|
||||||
let db = Db::connect(database_url)
|
// CLI one-shot: a tiny pool is plenty.
|
||||||
|
let db = Db::connect(database_url, 2)
|
||||||
.await
|
.await
|
||||||
.context("connecting to the database")?;
|
.context("connecting to the database")?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use clap::{Parser, Subcommand, ValueEnum};
|
use clap::{Parser, Subcommand, ValueEnum};
|
||||||
use domain::Role;
|
use domain::Role;
|
||||||
use server::{Config, create_user, run};
|
use server::{Config, create_user, run, seed};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(version, about = "Collection management system server")]
|
#[command(version, about = "Collection management system server")]
|
||||||
@@ -20,6 +20,8 @@ enum Command {
|
|||||||
#[arg(long, value_enum)]
|
#[arg(long, value_enum)]
|
||||||
role: RoleArg,
|
role: RoleArg,
|
||||||
},
|
},
|
||||||
|
/// Seed the baseline Spectrum cataloguing vocabularies + field definitions (idempotent).
|
||||||
|
Seed,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, ValueEnum)]
|
#[derive(Clone, Copy, ValueEnum)]
|
||||||
@@ -39,6 +41,10 @@ impl From<RoleArg> for Role {
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
// Load a .env file (if present) so the binary picks up config when run directly,
|
||||||
|
// not only via `just` (which uses `set dotenv-load`). A missing .env is fine.
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
.init();
|
.init();
|
||||||
@@ -50,5 +56,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Some(Command::CreateUser { email, role }) => {
|
Some(Command::CreateUser { email, role }) => {
|
||||||
create_user(&cli.config.database_url, &email, role.into()).await
|
create_user(&cli.config.database_url, &email, role.into()).await
|
||||||
}
|
}
|
||||||
|
Some(Command::Seed) => seed(&cli.config.database_url).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use server::Config;
|
use server::Config;
|
||||||
|
|
||||||
const CLEARED: [(&str, Option<&str>); 4] = [
|
const CLEARED: [(&str, Option<&str>); 6] = [
|
||||||
("DATABASE_URL", None),
|
("DATABASE_URL", None),
|
||||||
("BIND_ADDR", None),
|
("BIND_ADDR", None),
|
||||||
("APP_NAME", None),
|
("APP_NAME", None),
|
||||||
("SESSION_COOKIE_SECURE", None),
|
("SESSION_COOKIE_SECURE", None),
|
||||||
|
("DEFAULT_LANGUAGE", None),
|
||||||
|
("DEFAULT_TIMEZONE", None),
|
||||||
];
|
];
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -17,6 +19,8 @@ fn parses_from_args_with_defaults() {
|
|||||||
assert_eq!(cfg.database_url, "postgres://localhost/test");
|
assert_eq!(cfg.database_url, "postgres://localhost/test");
|
||||||
assert_eq!(cfg.bind_addr, "0.0.0.0:8080");
|
assert_eq!(cfg.bind_addr, "0.0.0.0:8080");
|
||||||
assert_eq!(cfg.app_name, "Collection Management System");
|
assert_eq!(cfg.app_name, "Collection Management System");
|
||||||
|
assert_eq!(cfg.default_language, "sv");
|
||||||
|
assert_eq!(cfg.default_timezone, "Europe/Stockholm");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use db::{Db, fields, seed, vocab};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
// Note: `server::seed` opens its own DB connection by URL, but `#[sqlx::test]`
|
||||||
|
// provisions a temporary database whose URL is not directly exposed. This test
|
||||||
|
// exercises the building block the command composes — `db::seed::seed_spectrum_cataloguing`
|
||||||
|
// — against the test pool, run twice to prove the idempotency the command relies on.
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn seed_is_idempotent_via_building_block(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
|
for _ in 0..2 {
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
seed::seed_spectrum_cataloguing(&mut tx).await.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A representative seeded vocabulary and field definition are present after two runs.
|
||||||
|
assert!(
|
||||||
|
vocab::vocabulary_by_key(db.pool(), "material")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_some(),
|
||||||
|
"vocabulary 'material' should be seeded"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fields::field_definition_by_key(db.pool(), "title")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_some(),
|
||||||
|
"field definition 'title' should be seeded"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ use tokio::net::TcpListener;
|
|||||||
async fn serves_health_live_over_tcp() {
|
async fn serves_health_live_over_tcp() {
|
||||||
let database_url =
|
let database_url =
|
||||||
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for this test");
|
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for this test");
|
||||||
let db = Db::connect(&database_url)
|
let db = Db::connect(&database_url, 2)
|
||||||
.await
|
.await
|
||||||
.expect("connect to database");
|
.expect("connect to database");
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
@@ -17,18 +17,28 @@ async fn serves_health_live_over_tcp() {
|
|||||||
app_name: "Test".to_string(),
|
app_name: "Test".to_string(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let addr: SocketAddr = listener.local_addr().unwrap();
|
let addr: SocketAddr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move { serve(listener, state).await });
|
||||||
serve(listener, state).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
let url = format!("http://{addr}/health/live");
|
let url = format!("http://{addr}/health/live");
|
||||||
let body: serde_json::Value = reqwest::get(&url)
|
let response = reqwest::get(&url).await;
|
||||||
.await
|
|
||||||
|
// If the request failed and the server task already ended, it errored — surface that
|
||||||
|
// (a clear server error) instead of the opaque reqwest failure.
|
||||||
|
if response.is_err() && handle.is_finished() {
|
||||||
|
match handle.await {
|
||||||
|
Ok(Err(err)) => panic!("server failed: {err:?}"),
|
||||||
|
other => panic!("server task ended unexpectedly: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: serde_json::Value = response
|
||||||
.expect("request succeeds")
|
.expect("request succeeds")
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ services:
|
|||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
meilisearch:
|
||||||
|
image: getmeili/meilisearch:v1.12
|
||||||
|
environment:
|
||||||
|
# Development mode relaxes the production master-key length requirement and
|
||||||
|
# enables the search-preview UI. The key below is for local use only.
|
||||||
|
MEILI_ENV: development
|
||||||
|
MEILI_MASTER_KEY: masterKey
|
||||||
|
ports:
|
||||||
|
- "7700:7700"
|
||||||
|
volumes:
|
||||||
|
- meilidata:/meili_data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
meilidata:
|
||||||
|
|||||||
@@ -0,0 +1,922 @@
|
|||||||
|
# Fields Management Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let admins create flexible field definitions — expose `POST /api/admin/field-definitions` over the existing db layer, and build a `/fields` two-pane screen (grouped list + create form) that enables the last nav stub.
|
||||||
|
|
||||||
|
**Architecture:** A thin axum write handler reuses `FieldType::from_parts` as the single type/binding validation chokepoint and `db::fields::create_field_definition`. The frontend reuses the Objects/Vocabularies two-pane idiom: a grouped read-only list (`useFieldDefinitions`, already cached and shared with the M2 object editor) plus a create form with native `<select>`s and conditional config (vocabulary for `term`, kind for `authority`). Creating a field invalidates `["field-definitions"]`, so it appears in both the list and the object editor.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (axum 0.8, utoipa, sqlx 0.8), React 19 + TS, TanStack Query v5, react-router-dom 7, react-i18next (sv/en), Vitest + RTL + MSW.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-04-fields-management-design.md`
|
||||||
|
|
||||||
|
**Conventions (every task):**
|
||||||
|
- Rust fmt with **nightly** (`cargo +nightly fmt`); `cargo clippy`.
|
||||||
|
- Frontend: no `any` / `eslint-disable` / `@ts-ignore`; en/sv i18n key parity; codename "biggus"/"dickus" nowhere; native `<select>` for dropdowns (matches `web/src/objects/field-input.tsx` — a deliberate bundle-lean choice).
|
||||||
|
- Test infra (running docker containers; start if down): `DATABASE_URL=postgres://postgres:postgres@localhost:5433/cms_dev`, `MEILI_URL=http://localhost:7701`, `MEILI_MASTER_KEY=masterKey`. (Field-definition tests need only Postgres; `#[sqlx::test]` provisions its own DB.)
|
||||||
|
- Run web commands from `web/`; cargo from repo root.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Backend — `POST /api/admin/field-definitions`
|
||||||
|
|
||||||
|
The GET handler already lives in `crates/api/src/admin_objects.rs` and its route is registered there. **axum panics if the same path is declared in two merged routers**, so the POST handler goes in `admin_objects.rs` too and chains `.post(...)` onto the existing `.route("/api/admin/field-definitions", get(list_field_definitions))`. No domain or db changes — `FieldType::from_parts` and `db::fields::create_field_definition` already exist.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/api/src/admin_objects.rs` (add request/response structs, handler, chain `.post`)
|
||||||
|
- Modify: `crates/api/src/openapi.rs` (register path + schemas)
|
||||||
|
- Test: `crates/api/tests/admin_fields.rs` (new)
|
||||||
|
- Regenerate: `web/src/api/schema.d.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing API test** — create `crates/api/tests/admin_fields.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use api::{AppState, build_app, migrate_sessions};
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode, header};
|
||||||
|
use db::users;
|
||||||
|
use domain::{AuditActor, Email, NewUser, Role};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
fn state(pool: PgPool) -> AppState {
|
||||||
|
AppState {
|
||||||
|
db: db::Db::from_pool(pool),
|
||||||
|
app_name: "Test".into(),
|
||||||
|
cookie_secure: false,
|
||||||
|
search: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) {
|
||||||
|
let db = db::Db::from_pool(pool.clone());
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
users::create_user(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&NewUser {
|
||||||
|
email: Email::parse(email).unwrap(),
|
||||||
|
password_hash: auth::hash_password(password).unwrap(),
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(app: &axum::Router, email: &str, password: &str) -> String {
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/admin/login")
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(format!(
|
||||||
|
r#"{{"email":"{email}","password":"{password}"}}"#
|
||||||
|
)))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||||
|
resp.headers()
|
||||||
|
.get(header::SET_COOKIE)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_field(app: &axum::Router, cookie: &str, body: &str) -> axum::http::Response<Body> {
|
||||||
|
app.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/admin/field-definitions")
|
||||||
|
.header(header::COOKIE, cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(body.to_owned()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn create_requires_auth(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/admin/field-definitions")
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"key":"x","data_type":"text","required":false,"labels":[{"lang":"en","label":"X"}]}"#,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn create_scalar_field_then_lists_it(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let resp = post_field(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
r#"{"key":"height_cm","data_type":"integer","required":true,"group":"Dimensions","labels":[{"lang":"en","label":"Height"},{"lang":"sv","label":"Höjd"}]}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
assert_eq!(body["key"], "height_cm");
|
||||||
|
|
||||||
|
// It appears in the GET listing.
|
||||||
|
let list = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/admin/field-definitions")
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let defs: serde_json::Value =
|
||||||
|
serde_json::from_slice(&list.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
assert!(defs.as_array().unwrap().iter().any(|d| d["key"] == "height_cm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn term_without_vocabulary_is_422(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let resp = post_field(
|
||||||
|
&app,
|
||||||
|
&cookie,
|
||||||
|
r#"{"key":"material","data_type":"term","required":false,"labels":[{"lang":"en","label":"Material"}]}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn duplicate_key_is_409(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
|
||||||
|
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||||
|
|
||||||
|
let body = r#"{"key":"dup","data_type":"text","required":false,"labels":[{"lang":"en","label":"Dup"}]}"#;
|
||||||
|
assert_eq!(post_field(&app, &cookie, body).await.status(), StatusCode::CREATED);
|
||||||
|
assert_eq!(post_field(&app, &cookie, body).await.status(), StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it to confirm it fails** —
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p api --test admin_fields
|
||||||
|
```
|
||||||
|
Expected: 401-test may pass incidentally, but the create tests fail (route has no POST → 405/404).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the request/response structs + handler** — in `crates/api/src/admin_objects.rs`. First ensure the imports at the top include what's needed (the file already imports axum bits, `State`, `StatusCode`, `Json`, `db`, `auth::{Authorized, ViewInternal}`; add `EditCatalogue` and the domain types). Add to the `use auth::...` line: `EditCatalogue`. Add `use domain::{AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition, VocabularyId};` if not already present (the file may already import some domain types — merge, don't duplicate). Reuse `LabelInput` — it is defined in `admin_vocab`; import it: `use crate::admin_vocab::LabelInput;` (the file already imports from `crate`; add this).
|
||||||
|
|
||||||
|
Then add the structs (near `FieldDefinitionView`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub(crate) struct NewFieldDefinitionRequest {
|
||||||
|
pub key: String,
|
||||||
|
/// text | localized_text | integer | date | boolean | term | authority
|
||||||
|
pub data_type: String,
|
||||||
|
pub vocabulary_id: Option<String>,
|
||||||
|
pub authority_kind: Option<String>,
|
||||||
|
pub required: bool,
|
||||||
|
pub group: Option<String>,
|
||||||
|
pub labels: Vec<LabelInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||||
|
pub(crate) struct CreatedField {
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(If `serde::{Deserialize, Serialize}` and `utoipa::ToSchema` are already imported in this file, use the bare derive names to match the file's style.)
|
||||||
|
|
||||||
|
And the handler:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Create a field definition. Requires `EditCatalogue`. All type/binding consistency
|
||||||
|
/// (term needs a vocabulary, authority takes no vocabulary, scalars take no binding) is
|
||||||
|
/// validated by `FieldType::from_parts`, which returns `None` for any bad combination.
|
||||||
|
#[utoipa::path(
|
||||||
|
post, path = "/api/admin/field-definitions",
|
||||||
|
request_body = NewFieldDefinitionRequest,
|
||||||
|
responses(
|
||||||
|
(status = 201, body = CreatedField),
|
||||||
|
(status = 400, description = "Malformed vocabulary_id or authority_kind"),
|
||||||
|
(status = 401),
|
||||||
|
(status = 403),
|
||||||
|
(status = 409, description = "Duplicate key"),
|
||||||
|
(status = 422, description = "Inconsistent type/binding")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn create_field_definition(
|
||||||
|
_auth: Authorized<EditCatalogue>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<NewFieldDefinitionRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<CreatedField>), StatusCode> {
|
||||||
|
let vocabulary_id = match req.vocabulary_id.as_deref() {
|
||||||
|
None | Some("") => None,
|
||||||
|
Some(s) => Some(s.parse::<VocabularyId>().map_err(|_| StatusCode::BAD_REQUEST)?),
|
||||||
|
};
|
||||||
|
let authority_kind = match req.authority_kind.as_deref() {
|
||||||
|
None | Some("") => None,
|
||||||
|
Some(s) => Some(AuthorityKind::from_db(s).ok_or(StatusCode::BAD_REQUEST)?),
|
||||||
|
};
|
||||||
|
|
||||||
|
let field_type = FieldType::from_parts(&req.data_type, vocabulary_id, authority_kind)
|
||||||
|
.ok_or(StatusCode::UNPROCESSABLE_ENTITY)?;
|
||||||
|
|
||||||
|
let new = NewFieldDefinition {
|
||||||
|
key: req.key,
|
||||||
|
field_type,
|
||||||
|
required: req.required,
|
||||||
|
group_key: req.group,
|
||||||
|
labels: req
|
||||||
|
.labels
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| LocalizedLabel {
|
||||||
|
lang: l.lang,
|
||||||
|
label: l.label,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tx = state
|
||||||
|
.db
|
||||||
|
.pool()
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
match db::fields::create_field_definition(&mut tx, &new).await {
|
||||||
|
Ok(_) => {
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(CreatedField { key: new.key })))
|
||||||
|
}
|
||||||
|
// Duplicate `key` violates the unique index (SQLSTATE 23505).
|
||||||
|
Err(err)
|
||||||
|
if err
|
||||||
|
.as_database_error()
|
||||||
|
.and_then(|e| e.code())
|
||||||
|
.as_deref()
|
||||||
|
== Some("23505") =>
|
||||||
|
{
|
||||||
|
Err(StatusCode::CONFLICT)
|
||||||
|
}
|
||||||
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Note: `AuthorityKind::from_db` is the existing parser (`crates/domain/src/authority.rs`); confirm the method name there (it is `from_db`, returning `Option<AuthorityKind>`). `VocabularyId: FromStr` is used the same way `admin_vocab` parses ids.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Chain `.post` onto the existing route** — in `admin_objects.rs` `routes()`, change:
|
||||||
|
```rust
|
||||||
|
.route("/api/admin/field-definitions", get(list_field_definitions))
|
||||||
|
```
|
||||||
|
to
|
||||||
|
```rust
|
||||||
|
.route(
|
||||||
|
"/api/admin/field-definitions",
|
||||||
|
get(list_field_definitions).post(create_field_definition),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Register in OpenAPI** — in `crates/api/src/openapi.rs`: add `admin_objects::create_field_definition` to `paths(...)`; add `admin_objects::NewFieldDefinitionRequest` and `admin_objects::CreatedField` to `components(schemas(...))`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the API tests** — `cargo test -p api --test admin_fields` → 4 pass.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Regenerate the typed web client** —
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build -p server
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5433/cms_dev \
|
||||||
|
MEILI_URL=http://localhost:7701 MEILI_MASTER_KEY=masterKey \
|
||||||
|
./target/debug/server &
|
||||||
|
SERVER_PID=$!
|
||||||
|
sleep 2
|
||||||
|
( cd web && pnpm gen:api )
|
||||||
|
kill "$SERVER_PID"
|
||||||
|
grep -n "NewFieldDefinitionRequest\|CreatedField" web/src/api/schema.d.ts
|
||||||
|
```
|
||||||
|
The grep must show both schemas. Then `cd web && pnpm typecheck` to confirm the regenerated file is well-formed (the diff should be purely additive — the existing `/api/admin/field-definitions` GET path gains a `post` operation; no existing paths removed). If a stale server occupies :8080, kill it first (`lsof -ti :8080 | xargs kill`).
|
||||||
|
|
||||||
|
- [ ] **Step 8: Format, lint, commit** —
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo +nightly fmt
|
||||||
|
cargo clippy -p api --all-targets
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add crates/api web/src/api/schema.d.ts
|
||||||
|
git commit -m "feat(api): POST /api/admin/field-definitions (create field definition)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Frontend data layer — `useCreateFieldDefinition` + MSW handler
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/api/queries.ts`, `web/src/test/handlers.ts`
|
||||||
|
- Test: `web/src/api/queries.fields.test.tsx` (new)
|
||||||
|
|
||||||
|
The `fieldDefinitions` GET fixture already exists (`web/src/test/fixtures.ts`) with a grouped entry (`inscription`, group "Description") and ungrouped entries, and the GET handler is already wired. Only the mutation + POST handler are new.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the MSW POST handler** — in `web/src/test/handlers.ts`, add to the `handlers` array:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
http.post("/api/admin/field-definitions", () =>
|
||||||
|
HttpResponse.json({ key: "new_field" }, { status: 201 }),
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing hook test** — create `web/src/api/queries.fields.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { useCreateFieldDefinition } from "./queries";
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||||
|
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("useCreateFieldDefinition POSTs the request body", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/field-definitions", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return HttpResponse.json({ key: "technique" }, { status: 201 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useCreateFieldDefinition(), { wrapper });
|
||||||
|
result.current.mutate({
|
||||||
|
key: "technique",
|
||||||
|
data_type: "term",
|
||||||
|
vocabulary_id: "v-technique",
|
||||||
|
authority_kind: null,
|
||||||
|
required: false,
|
||||||
|
group: "Provenance",
|
||||||
|
labels: [{ lang: "en", label: "Technique" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
expect((body as { key: string; data_type: string }).key).toBe("technique");
|
||||||
|
expect((body as { data_type: string }).data_type).toBe("term");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run it to confirm it fails** — `cd web && pnpm test src/api/queries.fields.test.tsx` → FAIL (no `useCreateFieldDefinition`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement the hook** — in `web/src/api/queries.ts`, append (it uses the already-imported `useMutation`, `useQueryClient`, `api`, and `components`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type NewFieldDefinitionRequest = components["schemas"]["NewFieldDefinitionRequest"];
|
||||||
|
|
||||||
|
export function useCreateFieldDefinition() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: NewFieldDefinitionRequest) => {
|
||||||
|
const { data, response } = await api.POST("/api/admin/field-definitions", { body });
|
||||||
|
|
||||||
|
if (response.status !== 201 || !data) throw new Error("failed to create field definition");
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run it to confirm it passes** — `pnpm test src/api/queries.fields.test.tsx` → PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit** —
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add web
|
||||||
|
git commit -m "feat(web): useCreateFieldDefinition mutation + MSW handler"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Frontend — `/fields` two-pane screen, route, nav, i18n
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/fields/fields-page.tsx`, `web/src/fields/field-list.tsx`, `web/src/fields/field-form.tsx`, `web/src/fields/fields.test.tsx`
|
||||||
|
- Modify: `web/src/app.tsx`, `web/src/shell/app-shell.tsx`, `web/src/i18n/{en,sv}.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n** — add a `fields` namespace to BOTH `web/src/i18n/en.json` and `sv.json` (keep parity; authority-kind option labels reuse the existing `authorities.{person,organisation,place}` keys).
|
||||||
|
`en.json`:
|
||||||
|
```json
|
||||||
|
"fields": {
|
||||||
|
"title": "Fields",
|
||||||
|
"newField": "New field definition",
|
||||||
|
"key": "Key",
|
||||||
|
"type": "Type",
|
||||||
|
"vocabulary": "Vocabulary",
|
||||||
|
"authorityKind": "Authority kind",
|
||||||
|
"anyKind": "Any",
|
||||||
|
"group": "Group",
|
||||||
|
"required": "Required",
|
||||||
|
"create": "Create field",
|
||||||
|
"empty": "No field definitions yet",
|
||||||
|
"loadError": "Could not load",
|
||||||
|
"other": "Other",
|
||||||
|
"types": {
|
||||||
|
"text": "Text",
|
||||||
|
"localized_text": "Localized text",
|
||||||
|
"integer": "Integer",
|
||||||
|
"date": "Date",
|
||||||
|
"boolean": "Boolean",
|
||||||
|
"term": "Term",
|
||||||
|
"authority": "Authority"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`sv.json`:
|
||||||
|
```json
|
||||||
|
"fields": {
|
||||||
|
"title": "Fält",
|
||||||
|
"newField": "Nytt fältdefinition",
|
||||||
|
"key": "Nyckel",
|
||||||
|
"type": "Typ",
|
||||||
|
"vocabulary": "Vokabulär",
|
||||||
|
"authorityKind": "Auktoritetstyp",
|
||||||
|
"anyKind": "Alla",
|
||||||
|
"group": "Grupp",
|
||||||
|
"required": "Obligatoriskt",
|
||||||
|
"create": "Skapa fält",
|
||||||
|
"empty": "Inga fältdefinitioner ännu",
|
||||||
|
"loadError": "Kunde inte ladda",
|
||||||
|
"other": "Övrigt",
|
||||||
|
"types": {
|
||||||
|
"text": "Text",
|
||||||
|
"localized_text": "Lokaliserad text",
|
||||||
|
"integer": "Heltal",
|
||||||
|
"date": "Datum",
|
||||||
|
"boolean": "Boolesk",
|
||||||
|
"term": "Term",
|
||||||
|
"authority": "Auktoritet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement `FieldList`** — create `web/src/fields/field-list.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { useFieldDefinitions } from "../api/queries";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
||||||
|
|
||||||
|
function labelText(labels: FieldDefinitionView["labels"], lang: string): string {
|
||||||
|
return (
|
||||||
|
labels.find((l) => l.lang === lang)?.label ??
|
||||||
|
labels.find((l) => l.lang === "en")?.label ??
|
||||||
|
labels[0]?.label ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldList() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { data, isLoading, isError } = useFieldDefinitions();
|
||||||
|
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-9 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) return <p className="p-4 text-sm text-red-600">{t("fields.loadError")}</p>;
|
||||||
|
if (!data || data.length === 0)
|
||||||
|
return <p className="p-4 text-sm text-neutral-500">{t("fields.empty")}</p>;
|
||||||
|
|
||||||
|
// Group by `group`; ungrouped (null/empty) collected under the "Other" heading.
|
||||||
|
const groups = new Map<string, FieldDefinitionView[]>();
|
||||||
|
for (const def of data) {
|
||||||
|
const key = def.group?.trim() ? def.group : t("fields.other");
|
||||||
|
const bucket = groups.get(key) ?? [];
|
||||||
|
bucket.push(def);
|
||||||
|
groups.set(key, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="overflow-auto">
|
||||||
|
{[...groups.entries()].map(([group, defs]) => (
|
||||||
|
<li key={group}>
|
||||||
|
<div className="border-b bg-neutral-50 px-3 py-1 text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||||
|
{group}
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{defs.map((def) => (
|
||||||
|
<li key={def.key} className="flex items-center gap-2 border-b px-3 py-2 text-sm">
|
||||||
|
<span className="font-medium">{labelText(def.labels, lang)}</span>
|
||||||
|
<span className="text-xs text-neutral-400">{def.key}</span>
|
||||||
|
<span className="rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600">
|
||||||
|
{t(`fields.types.${def.data_type}`)}
|
||||||
|
</span>
|
||||||
|
{def.required && <span className="text-xs text-red-600">*</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `FieldForm`** — create `web/src/fields/field-form.tsx`. Native `<select>`s (matches `web/src/objects/field-input.tsx`). Reuses `LabelEditor` (sv/en, EN-required) and `useVocabularies`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { useCreateFieldDefinition, useVocabularies } from "../api/queries";
|
||||||
|
import { LabelEditor } from "../components/label-editor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
|
const TYPES = ["text", "localized_text", "integer", "date", "boolean", "term", "authority"] as const;
|
||||||
|
const KINDS = ["person", "organisation", "place"] as const;
|
||||||
|
|
||||||
|
export function FieldForm() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const create = useCreateFieldDefinition();
|
||||||
|
const { data: vocabularies } = useVocabularies();
|
||||||
|
|
||||||
|
const [key, setKey] = useState("");
|
||||||
|
const [labels, setLabels] = useState<LabelInput[]>([]);
|
||||||
|
const [dataType, setDataType] = useState<string>("text");
|
||||||
|
const [vocabularyId, setVocabularyId] = useState("");
|
||||||
|
const [authorityKind, setAuthorityKind] = useState(""); // "" == any
|
||||||
|
const [group, setGroup] = useState("");
|
||||||
|
const [required, setRequired] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setKey("");
|
||||||
|
setLabels([]);
|
||||||
|
setDataType("text");
|
||||||
|
setVocabularyId("");
|
||||||
|
setAuthorityKind("");
|
||||||
|
setGroup("");
|
||||||
|
setRequired(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const hasEn = labels.some((l) => l.lang === "en" && l.label);
|
||||||
|
const termNeedsVocab = dataType === "term" && !vocabularyId;
|
||||||
|
|
||||||
|
if (!key.trim() || !hasEn || termNeedsVocab) {
|
||||||
|
setError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(false);
|
||||||
|
create.mutate(
|
||||||
|
{
|
||||||
|
key: key.trim(),
|
||||||
|
data_type: dataType,
|
||||||
|
vocabulary_id: dataType === "term" ? vocabularyId : null,
|
||||||
|
authority_kind: dataType === "authority" ? authorityKind || null : null,
|
||||||
|
required,
|
||||||
|
group: group.trim() || null,
|
||||||
|
labels,
|
||||||
|
},
|
||||||
|
{ onSuccess: reset },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-3 overflow-auto p-4">
|
||||||
|
<div className="text-sm font-medium">{t("fields.newField")}</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="field-key">{t("fields.key")}</Label>
|
||||||
|
<Input id="field-key" value={key} onChange={(e) => setKey(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LabelEditor value={labels} onChange={setLabels} />
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="field-type">{t("fields.type")}</Label>
|
||||||
|
<select
|
||||||
|
id="field-type"
|
||||||
|
value={dataType}
|
||||||
|
onChange={(e) => setDataType(e.target.value)}
|
||||||
|
className="w-full rounded border px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
{TYPES.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{t(`fields.types.${type}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dataType === "term" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="field-vocab">{t("fields.vocabulary")}</Label>
|
||||||
|
<select
|
||||||
|
id="field-vocab"
|
||||||
|
value={vocabularyId}
|
||||||
|
onChange={(e) => setVocabularyId(e.target.value)}
|
||||||
|
className="w-full rounded border px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">{t("form.selectPlaceholder")}</option>
|
||||||
|
{vocabularies?.map((vocab) => (
|
||||||
|
<option key={vocab.id} value={vocab.id}>
|
||||||
|
{vocab.key}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dataType === "authority" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="field-kind">{t("fields.authorityKind")}</Label>
|
||||||
|
<select
|
||||||
|
id="field-kind"
|
||||||
|
value={authorityKind}
|
||||||
|
onChange={(e) => setAuthorityKind(e.target.value)}
|
||||||
|
className="w-full rounded border px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">{t("fields.anyKind")}</option>
|
||||||
|
{KINDS.map((kind) => (
|
||||||
|
<option key={kind} value={kind}>
|
||||||
|
{t(`authorities.${kind}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="field-group">{t("fields.group")}</Label>
|
||||||
|
<Input id="field-group" value={group} onChange={(e) => setGroup(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked={required} onCheckedChange={(checked) => setRequired(checked === true)} />
|
||||||
|
{t("fields.required")}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p role="alert" className="text-xs text-red-600">{t("form.required")}</p>}
|
||||||
|
{create.isError && <p role="alert" className="text-xs text-red-600">{t("form.rejected")}</p>}
|
||||||
|
|
||||||
|
<Button type="submit" size="sm" disabled={create.isPending}>
|
||||||
|
{t("fields.create")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Before finishing: open `web/src/components/ui/checkbox.tsx` and confirm the controlled API is `checked` + `onCheckedChange(checked: boolean)` (base-ui). If the signature differs, adapt the `<Checkbox>` usage (no `any`). Also confirm `@/components/ui/label` exports `Label` (the vocab/object forms use it).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement `FieldsPage`** — create `web/src/fields/fields-page.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FieldList } from "./field-list";
|
||||||
|
import { FieldForm } from "./field-form";
|
||||||
|
|
||||||
|
export function FieldsPage() {
|
||||||
|
return (
|
||||||
|
<div className="grid h-full grid-cols-[20rem_1fr]">
|
||||||
|
<div className="overflow-hidden border-r">
|
||||||
|
<FieldList />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<FieldForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Wire the route** — in `web/src/app.tsx`, import `import { FieldsPage } from "./fields/fields-page";` and add inside the `<AppShell>` group:
|
||||||
|
```tsx
|
||||||
|
<Route path="/fields" element={<FieldsPage />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Enable the Fields nav** — in `web/src/shell/app-shell.tsx`:
|
||||||
|
- change `const DISABLED_NAV = ["fields"] as const;` to `const DISABLED_NAV = [] as const;`
|
||||||
|
- add a Fields `NavLink` after the Search NavLink (before the `DISABLED_NAV.map(...)`):
|
||||||
|
```tsx
|
||||||
|
<NavLink
|
||||||
|
to="/fields"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("nav.fields")}
|
||||||
|
</NavLink>
|
||||||
|
```
|
||||||
|
The `DISABLED_NAV.map(...)` block now renders nothing (empty array) — that is fine; leave it, or remove it if eslint flags an unused `nav.soon`. (`nav.soon` may become unused — if `pnpm lint`/parity complains, leave the key in both i18n files; an unused i18n key is harmless and keeps parity.)
|
||||||
|
|
||||||
|
- [ ] **Step 7: Write the integration test** — create `web/src/fields/fields.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { FieldsPage } from "./fields-page";
|
||||||
|
|
||||||
|
function tree() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/fields" element={<FieldsPage />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("lists field definitions grouped, with an Other heading for ungrouped", async () => {
|
||||||
|
renderApp(tree(), { route: "/fields" });
|
||||||
|
// grouped fixture entry (group "Description") and an ungrouped one ("Other")
|
||||||
|
expect(await screen.findByText("Inscription")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/^Description$/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/^Other$/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a text field — posts the body and clears the key input", async () => {
|
||||||
|
let body: { key: string; data_type: string } | undefined;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/field-definitions", async ({ request }) => {
|
||||||
|
body = (await request.json()) as { key: string; data_type: string };
|
||||||
|
return HttpResponse.json({ key: "notes" }, { status: 201 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp(tree(), { route: "/fields" });
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(/^key$/i), "notes");
|
||||||
|
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Notes");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /create field/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(body?.key).toBe("notes"));
|
||||||
|
expect(body?.data_type).toBe("text");
|
||||||
|
await waitFor(() => expect(screen.getByLabelText(/^key$/i)).toHaveValue(""));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting Term reveals the vocabulary picker and blocks submit until chosen", async () => {
|
||||||
|
let posted = false;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/field-definitions", () => {
|
||||||
|
posted = true;
|
||||||
|
return HttpResponse.json({ key: "x" }, { status: 201 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp(tree(), { route: "/fields" });
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(/^key$/i), "material");
|
||||||
|
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Material");
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "term");
|
||||||
|
|
||||||
|
// Vocabulary select now present.
|
||||||
|
const vocab = await screen.findByLabelText(/^vocabulary$/i);
|
||||||
|
expect(vocab).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Submit without choosing a vocabulary → blocked, alert shown, no POST.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /create field/i }));
|
||||||
|
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
||||||
|
expect(posted).toBe(false);
|
||||||
|
|
||||||
|
// Choose one (fixture vocabularies: v-material/material, v-technique/technique) → posts.
|
||||||
|
await userEvent.selectOptions(vocab, "v-material");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /create field/i }));
|
||||||
|
await waitFor(() => expect(posted).toBe(true));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Run `pnpm test src/fields/fields.test.tsx`. If `getByLabelText(/^key$/i)` is ambiguous (the EN/SV label inputs from `LabelEditor` use `labels.en`/`labels.sv` text), the anchored `/^key$/i` should match only the "Key" `<Label htmlFor="field-key">`; if not, scope with the field id. The `vocabularies` fixture is the existing one (`v-material`/`material`, `v-technique`/`technique`).
|
||||||
|
|
||||||
|
- [ ] **Step 8: Update the app-shell test** — open `web/src/shell/app-shell.test.tsx`. It currently asserts `fields` (and/or `search`) is a disabled button. Update so **Fields is now a link** (`getByRole("link", { name: /fields/i })`); there are no disabled nav buttons left — if a test asserted a disabled button exists, remove/replace that assertion. Run `pnpm test src/shell/app-shell.test.tsx` → PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Full verify** — `pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size`. Report the bundle gz number. If `check:size` > 150 KB gz, lazy-load `/fields` in `app.tsx` (mirror the `ObjectNewPage` lazy pattern: `const FieldsPage = lazy(() => import("./fields/fields-page").then((m) => ({ default: m.FieldsPage })))` + wrap the route element in `<Suspense fallback={<FormFallback />}>`), then re-run check:size.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit** —
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add web
|
||||||
|
git commit -m "feat(web): /fields two-pane screen (grouped list + create form) + nav (no stubs left)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: i18n parity + full verification
|
||||||
|
|
||||||
|
**Files:** none expected (verification); fix-ups only if a check fails.
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n parity** —
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const k=o=>Object.entries(o).flatMap(([K,v])=>typeof v==='object'?k(v).map(s=>K+'.'+s):[K]);const ka=k(a).sort(),kb=k(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH '+JSON.stringify({onlyEn:ka.filter(x=>!kb.includes(x)),onlySv:kb.filter(x=>!ka.includes(x))}))"
|
||||||
|
```
|
||||||
|
Expected `PARITY OK`; fix any mismatch.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Full frontend verification** —
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
|
||||||
|
```
|
||||||
|
Expected clean; all tests pass; bundle ≤150 KB gz (report the number).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Full backend verification** —
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5433/cms_dev \
|
||||||
|
MEILI_URL=http://localhost:7701 MEILI_MASTER_KEY=masterKey \
|
||||||
|
cargo test -p api
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo +nightly fmt --check
|
||||||
|
```
|
||||||
|
Expected: all pass; clippy clean; fmt clean.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit** — only if Steps 1–2 required a fix:
|
||||||
|
```bash
|
||||||
|
git add web
|
||||||
|
git commit -m "chore(web): fields management verification fix-ups"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- `POST /api/admin/field-definitions`, `EditCatalogue`, `from_parts` validation (422), dup key (409), malformed binding (400), auth → Task 1. ✓
|
||||||
|
- OpenAPI registration + regenerated client → Task 1. ✓
|
||||||
|
- `useCreateFieldDefinition` invalidating `["field-definitions"]` (shared with M2 editor) → Task 2. ✓
|
||||||
|
- Two-pane `/fields`: grouped list (+ "Other"), create form with conditional vocabulary/kind, native selects, LabelEditor reuse, EN-required + term-needs-vocab client validation, `form.rejected` on backend error → Task 3. ✓
|
||||||
|
- Nav enabled, `DISABLED_NAV = []` (no stubs) → Task 3. ✓
|
||||||
|
- i18n sv/en parity, bundle ≤150 KB, full backend+frontend verification → Task 4. ✓
|
||||||
|
- Create + list only (no edit/delete) — respected. ✓
|
||||||
|
|
||||||
|
**Placeholder scan:** none — every code step is complete; the two "confirm the Checkbox API / Label export" notes are concrete verification instructions against named files.
|
||||||
|
|
||||||
|
**Type consistency:** `NewFieldDefinitionRequest`/`CreatedField` (api) ↔ `components["schemas"]["NewFieldDefinitionRequest"]` (web `useCreateFieldDefinition` arg) consistent; `FieldDefinitionView` reused for the list; `data_type` string values (`text|localized_text|integer|date|boolean|term|authority`) match the `TYPES` tuple and the `fields.types.*` i18n keys; the `["field-definitions"]` query key matches `useFieldDefinitions`; `AuthorityKind::from_db`, `FieldType::from_parts`, `db::fields::create_field_definition(&mut tx, &new)`, and `VocabularyId` parse usage all match the confirmed backend signatures.
|
||||||
|
|
||||||
|
## Notes for follow-on
|
||||||
|
- Edit/delete field definitions — needs new `db::fields` update/delete + a referential-integrity policy (block/handle deleting a field objects reference or that is required). File a backend follow-up when this lands.
|
||||||
|
- Per-field validation rules (min/max/regex) — #11. Field/group reordering and renaming. Immutable `key`/`type` after creation.
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# Tier 2 Papercuts Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Clear a batch of small, well-specified correctness/observability/UX fixes from the issue tracker (#22, #18, #9, #4, #34, #31, #32, #37) — no new features.
|
||||||
|
|
||||||
|
**Architecture:** Independent small fixes grouped by area into four tasks: backend API behaviour (#22, #18), backend cleanup (#9, #4), frontend states/a11y (#34, #31, #32, #37), then verification.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (axum, sqlx, tracing), React + TS, TanStack Query, react-i18next, Vitest + RTL + MSW.
|
||||||
|
|
||||||
|
**Conventions (every task):** nightly `cargo +nightly fmt`; `cargo clippy`. Frontend: no `any`/`eslint-disable`/`@ts-ignore`; en/sv i18n parity; no codename "biggus"/"dickus". Test infra via compose: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev` (this machine's override port), `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey`. cargo from repo root; web from `web/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Backend API — 404 for missing vocabulary (#22) + log public 500s (#18)
|
||||||
|
|
||||||
|
**Files:** Modify `crates/api/src/admin_vocab.rs`, `crates/api/src/public.rs`; Test in the existing `crates/api/tests/admin_catalog.rs` (vocab/authority harness).
|
||||||
|
|
||||||
|
### #22 — `add_term` returns 404 when the vocabulary doesn't exist
|
||||||
|
Today `db::vocab::add_term(...)` maps every error to 500; a well-formed `{id}` for a missing vocabulary triggers a foreign-key violation (SQLSTATE 23503) that should be **404**.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing test** — add to `crates/api/tests/admin_catalog.rs` (mirror its existing seed-editor/login/oneshot harness). Read the file first to reuse its helpers:
|
||||||
|
```rust
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn add_term_to_missing_vocabulary_is_404(pool: PgPool) {
|
||||||
|
// (use this file's existing migrate_sessions + seed editor + login helpers)
|
||||||
|
let app = /* build_app with state */;
|
||||||
|
let cookie = /* login as editor */;
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/admin/vocabularies/00000000-0000-0000-0000-000000000000/terms")
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(r#"{"labels":[{"lang":"en","label":"X"}]}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Match the exact helper names/signatures already in `admin_catalog.rs`. If that file doesn't have a login helper, copy the pattern from `crates/api/tests/admin_fields.rs`.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run → fails** (currently 500): `cargo test -p api --test admin_catalog add_term_to_missing_vocabulary`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix** — in `crates/api/src/admin_vocab.rs` `add_term`, replace:
|
||||||
|
```rust
|
||||||
|
let term_id = db::vocab::add_term(&mut tx, &new)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
```
|
||||||
|
with:
|
||||||
|
```rust
|
||||||
|
let term_id = db::vocab::add_term(&mut tx, &new).await.map_err(|err| {
|
||||||
|
// A well-formed id for a missing vocabulary hits the FK constraint (23503).
|
||||||
|
if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23503") {
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
} else {
|
||||||
|
tracing::error!(?err, "adding term");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run → passes**, and confirm adding a term to an existing vocab still returns 201 (existing tests cover this).
|
||||||
|
|
||||||
|
### #18 — log the discarded `sqlx::Error` on public 500 paths
|
||||||
|
`crates/api/src/public.rs` discards errors via `.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)` (lines ~74, ~78) and `Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response()` (line ~109). `tracing` is already a dependency of the `api` crate — just log.
|
||||||
|
|
||||||
|
- [ ] **Step 5:** In `list_objects`, change both `.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?` to:
|
||||||
|
```rust
|
||||||
|
.map_err(|err| {
|
||||||
|
tracing::error!(?err, "listing public objects");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
```
|
||||||
|
(use a message specific to each call site — e.g. "listing public objects" and "counting public objects" — match what each query does).
|
||||||
|
|
||||||
|
- [ ] **Step 6:** In `get_object`, change the `Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response()` arm to bind and log the error:
|
||||||
|
```rust
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "fetching public object");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify** — `cargo +nightly fmt`, `cargo clippy -p api --all-targets`, `cargo test -p api`. Commit:
|
||||||
|
```bash
|
||||||
|
git add crates/api
|
||||||
|
git commit -m "fix(api): 404 when adding a term to a missing vocabulary (#22); log public 500s (#18)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Backend cleanup — enum/CHECK cross-refs (#9) + dead clone & test handle (#4)
|
||||||
|
|
||||||
|
**Files:** Modify `crates/domain/src/authority.rs`, `crates/domain/src/audit.rs`, `crates/server/src/lib.rs`, `crates/server/tests/serve.rs`.
|
||||||
|
|
||||||
|
> Do **not** edit any file under `crates/db/migrations/` — `sqlx::migrate!()` checksums applied migrations, so editing them (even a comment) breaks existing databases. The cross-reference comments go in the Rust enums only.
|
||||||
|
|
||||||
|
- [ ] **Step 1: #9 — cross-reference comments.**
|
||||||
|
- In `crates/domain/src/authority.rs`, above `pub enum AuthorityKind`, add:
|
||||||
|
```rust
|
||||||
|
/// Allowed kinds. NOTE: kept in sync by hand with the
|
||||||
|
/// `CHECK (kind IN ('person','organisation','place'))` constraint in
|
||||||
|
/// `crates/db/migrations/0002_vocabularies_authorities.sql` — update both together.
|
||||||
|
```
|
||||||
|
- In `crates/domain/src/audit.rs`, above `pub enum AuditAction`, add an equivalent comment pointing at the `action` CHECK in `crates/db/migrations/0001_*.sql` (open the migration to name the exact file + values).
|
||||||
|
|
||||||
|
- [ ] **Step 2: #4 — remove the dead clone.** In `crates/server/src/lib.rs` `run`, the `AppState` is built with `app_name: config.app_name.clone()`. Since `config.app_name` is a `String` and the only later use of `config` is the disjoint field `config.bind_addr`, change it to a move:
|
||||||
|
```rust
|
||||||
|
app_name: config.app_name,
|
||||||
|
```
|
||||||
|
Confirm it still compiles (partial move of one field; `&config.bind_addr` afterward is fine).
|
||||||
|
|
||||||
|
- [ ] **Step 3: #4 — smoke-test handle.** Open `crates/server/tests/serve.rs`. The spawned `serve(...)` task's `.unwrap()` swallows server errors as a task panic, surfacing as a confusing client error. Capture the `JoinHandle` and, after the assertions, either abort it cleanly or check it didn't error — make a server-start failure surface as a clear test failure rather than a `reqwest` error. Read the file and apply the minimal change that propagates/surfaces the server error (e.g. keep the handle, assert it hasn't finished-with-error, or `handle.abort()` at the end). Keep the test green.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify** — `cargo +nightly fmt`, `cargo clippy --workspace --all-targets`, `cargo test -p server -p domain`. Commit:
|
||||||
|
```bash
|
||||||
|
git add crates/domain crates/server
|
||||||
|
git commit -m "chore: cross-ref enum/CHECK constraints (#9); drop dead clone + harden smoke test (#4)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Frontend — search 503 (#34), list error states (#31), a11y + dead keys (#32), authority-kind test (#37)
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/api/queries.ts`, `web/src/search/search-panel.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/authorities/authorities-page.tsx`, `web/src/i18n/{en,sv}.json`, `web/src/fields/fields.test.tsx`; Tests in `web/src/search/search.test.tsx`, plus the vocab/authorities test files.
|
||||||
|
|
||||||
|
### #34 — distinguish search 503 ("unavailable") from a generic error
|
||||||
|
- [ ] **Step 1:** In `web/src/api/queries.ts`, add a tiny typed error and have `useSearch` throw it with the HTTP status (so the UI can branch without `any`). Near the top:
|
||||||
|
```ts
|
||||||
|
export class HttpError extends Error {
|
||||||
|
constructor(public readonly status: number) {
|
||||||
|
super(`HTTP ${status}`);
|
||||||
|
this.name = "HttpError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
In `useSearch`'s `queryFn`, replace `if (error || !data) throw new Error("search failed");` with:
|
||||||
|
```ts
|
||||||
|
if (error || !data) throw new HttpError(response.status);
|
||||||
|
```
|
||||||
|
(`response` is already destructured from `api.GET`; if not, add it.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: i18n** — add `search.unavailable` to BOTH `en.json` and `sv.json` (parity):
|
||||||
|
- en: `"unavailable": "Search is not available on this server"`
|
||||||
|
- sv: `"unavailable": "Sök är inte tillgängligt på den här servern"`
|
||||||
|
|
||||||
|
- [ ] **Step 3:** In `web/src/search/search-panel.tsx`, where `search.isError` renders `t("search.loadError")`, branch on a 503:
|
||||||
|
```tsx
|
||||||
|
{hasQuery && search.isError && (
|
||||||
|
<p className="p-4 text-sm text-red-600">
|
||||||
|
{search.error instanceof HttpError && search.error.status === 503
|
||||||
|
? t("search.unavailable")
|
||||||
|
: t("search.loadError")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
Import `HttpError` from `../api/queries`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Tests** — in `web/src/search/search.test.tsx`, add: a `503` response → renders `search.unavailable`; a `500` response → renders `search.loadError`. (Use `server.use(http.get("/api/admin/search", () => new HttpResponse(null, { status: 503 })))` etc., then type a query and assert the text.)
|
||||||
|
|
||||||
|
### #31 — loading/error states on the terms + authorities lists
|
||||||
|
- [ ] **Step 5:** In `web/src/vocab/vocabulary-terms.tsx`, the terms list uses `useTerms(id)` but renders empty/data only. Add `isLoading` (skeleton or `…`) and `isError` (`t("vocab.loadError")`) branches before the empty/data render, mirroring `vocabulary-list.tsx`'s state ladder.
|
||||||
|
- [ ] **Step 6:** In `web/src/authorities/authorities-page.tsx`, the list uses `useAuthorities(kind)`; add an `isError` branch rendering `t("authorities.loadError")` (currently a dead key — this uses it) and a loading branch. Keep the existing empty/data render.
|
||||||
|
- [ ] **Step 7: Tests** — add an error-state test to the vocab and authorities test files: MSW returns 500 for the terms / authorities GET → the respective `loadError` text appears. (Override the default handler with `server.use(...)`.)
|
||||||
|
|
||||||
|
### #32 — ARIA tab semantics + remove dead i18n keys
|
||||||
|
- [ ] **Step 8:** In `web/src/authorities/authorities-page.tsx`, the kind tabs are `NavLink`s. Add tab semantics: wrap them in a container with `role="tablist"`, give each `role="tab"` and `aria-selected={isActive}` (the `NavLink` className callback already exposes `isActive` — use the render-prop form to set `aria-selected`). Keep the existing styling.
|
||||||
|
- [ ] **Step 9:** Remove the unused keys `vocab.title` and `authorities.title` from BOTH `en.json` and `sv.json` (grep first: `grep -rn "vocab.title\|authorities.title\|\.title" web/src` — confirm only the i18n definitions match; nothing references them).
|
||||||
|
|
||||||
|
### #37 — frontend authority-kind reveal test
|
||||||
|
- [ ] **Step 10:** In `web/src/fields/fields.test.tsx`, add a test mirroring the existing Term test: type a key + EN label, `selectOptions(type, "authority")`, assert the authority-kind `<select>` (label `/authority kind/i`) appears, `selectOptions` it to `"person"`, submit, and assert the POST body's `authority_kind === "person"` (use a `server.use` POST handler that captures the body, like the Term test does).
|
||||||
|
|
||||||
|
- [ ] **Step 11: Verify** — `cd web && pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size`. All green; bundle ≤150 KB. Commit:
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add web
|
||||||
|
git commit -m "fix(web): search 503 vs error (#34); terms/authorities list error states (#31); authority-tab a11y + dead keys (#32); authority-kind test (#37)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n parity** —
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const k=o=>Object.entries(o).flatMap(([K,v])=>typeof v==='object'?k(v).map(s=>K+'.'+s):[K]);const ka=k(a).sort(),kb=k(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH '+JSON.stringify({onlyEn:ka.filter(x=>!kb.includes(x)),onlySv:kb.filter(x=>!ka.includes(x))}))"
|
||||||
|
```
|
||||||
|
Expected `PARITY OK`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Frontend** — `pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (report bundle gz).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Backend** —
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev \
|
||||||
|
MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey \
|
||||||
|
cargo test -p api -p domain -p server
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo +nightly fmt --check
|
||||||
|
```
|
||||||
|
All pass; clippy + fmt clean.
|
||||||
|
|
||||||
|
- [ ] **Step 4:** No codename: `git grep -in 'biggus\|dickus' -- crates web/src` → no matches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
- **Spec coverage:** #22 (404), #18 (log 500s) → Task 1; #9 (Rust cross-ref comments), #4 (clone + smoke test) → Task 2; #34, #31, #32, #37 → Task 3; parity + suites → Task 4. ✓
|
||||||
|
- **Scope adjustments baked in:** #8 already closed (thiserror is used); #37 backend-403 omitted (no non-EditCatalogue role exists); #9 Rust-side only (migration checksums). ✓
|
||||||
|
- **Placeholder scan:** none — code is concrete; the "match the existing harness" notes are verification instructions against named files.
|
||||||
|
- **Type consistency:** `HttpError` defined in queries.ts and imported in search-panel; the 23503/FK pattern matches the field-def handler; `authorities.loadError` (existing key) now consumed; `search.unavailable` added at parity.
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# Tier 3 — Typed-Client Quality Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||||
|
|
||||||
|
**Goal:** Tighten the generated OpenAPI/TypeScript contract so the frontend drops its `as`-casts — type the free-form `fields` map as an open map (#24) and the enum-valued fields (`visibility`, `data_type`, authority `kind`) as string enums (#29). Architecture decision #3 = **Option A** (allow `utoipa::ToSchema` in `domain`).
|
||||||
|
|
||||||
|
**Architecture:** `domain`'s already-serde enums gain `ToSchema`; a new `DataType` enum is added to `domain` for the `data_type` discriminant. The `api` View DTOs reference these via `#[schema(value_type = …)]` (fields stay `String`/`Value` at runtime; only the *schema description* changes). Regenerate `schema.d.ts`; remove the now-redundant frontend casts.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (utoipa 5, sqlx), React + TS, openapi-typescript.
|
||||||
|
|
||||||
|
**Conventions:** nightly fmt; clippy; no `any`/`eslint-disable`/`@ts-ignore`; no codename. Test infra: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev`, `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `domain` — `ToSchema` on enums + new `DataType`
|
||||||
|
|
||||||
|
**Files:** `crates/domain/Cargo.toml`, `crates/domain/src/object.rs`, `crates/domain/src/authority.rs`, `crates/domain/src/field_definition.rs`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the utoipa dep.** In `crates/domain/Cargo.toml` `[dependencies]`, add:
|
||||||
|
```toml
|
||||||
|
utoipa.workspace = true
|
||||||
|
```
|
||||||
|
(The workspace already defines `utoipa = { version = "5", features = ["uuid"] }`.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Derive `ToSchema` on `Visibility`** (`crates/domain/src/object.rs:7-9`). Add `utoipa::ToSchema` to the derive list (keep everything else):
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Visibility {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Derive `ToSchema` on `AuthorityKind`** (`crates/domain/src/authority.rs:10-12`):
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum AuthorityKind {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add a `DataType` enum** to `crates/domain/src/field_definition.rs` (it describes the `data_type` discriminant string that `FieldType::kind_str()` produces). NOTE: **`snake_case`**, so `LocalizedText` → `"localized_text"` (matching `kind_str`):
|
||||||
|
```rust
|
||||||
|
/// The stored `data_type` discriminant of a field definition. This mirrors the strings
|
||||||
|
/// produced by [`FieldType::kind_str`]; it exists so the OpenAPI schema can describe
|
||||||
|
/// `data_type` as a closed string enum (consumed by the typed web client). Kept in sync
|
||||||
|
/// by hand with `FieldType::kind_str`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DataType {
|
||||||
|
Text,
|
||||||
|
LocalizedText,
|
||||||
|
Integer,
|
||||||
|
Date,
|
||||||
|
Boolean,
|
||||||
|
Term,
|
||||||
|
Authority,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(If `serde::{Serialize, Deserialize}` are already imported at the top of the file, use the bare derive names; otherwise the fully-qualified `serde::Serialize` forms above are fine.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify** — `cargo +nightly fmt`, `cargo build -p domain`, `cargo clippy -p domain --all-targets`. The existing `field_type_round_trips` etc. tests still pass: `cargo test -p domain`. Add a tiny test asserting `DataType` serializes correctly (it must match `kind_str`):
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn data_type_serde_matches_kind_str() {
|
||||||
|
use serde_json::json;
|
||||||
|
assert_eq!(serde_json::to_value(DataType::LocalizedText).unwrap(), json!("localized_text"));
|
||||||
|
assert_eq!(serde_json::to_value(DataType::Text).unwrap(), json!("text"));
|
||||||
|
assert_eq!(serde_json::to_value(DataType::Authority).unwrap(), json!("authority"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(place it in the existing `#[cfg(test)] mod tests` in `field_definition.rs`).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add crates/domain
|
||||||
|
git commit -m "feat(domain): derive ToSchema on Visibility/AuthorityKind; add DataType enum (#3 Option A)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `api` — enum + open-map schema annotations + regenerate client
|
||||||
|
|
||||||
|
**Files:** `crates/api/src/admin_objects.rs`, `crates/api/src/admin_authorities.rs`, `crates/api/src/admin.rs`, `crates/api/src/openapi.rs`; regenerate `web/src/api/schema.d.ts`.
|
||||||
|
|
||||||
|
> The View fields keep their runtime types (`String` / `serde_json::Value`); only the `#[schema(value_type = …)]` annotation changes what the OpenAPI document says. No handler/construction logic changes.
|
||||||
|
|
||||||
|
- [ ] **Step 1: #24 — open-map `fields`.** In `crates/api/src/admin_objects.rs:45`, change `AdminObjectView.fields`:
|
||||||
|
```rust
|
||||||
|
#[schema(value_type = std::collections::HashMap<String, serde_json::Value>)]
|
||||||
|
pub fields: serde_json::Value,
|
||||||
|
```
|
||||||
|
(This is the only `value_type = Object` site — confirmed by `grep -rn "value_type = Object" crates/api/src`.) This makes utoipa emit `additionalProperties`, which `openapi-typescript` renders as `{ [key: string]: unknown }` instead of `Record<string, never>`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: #29 — `visibility` enums.**
|
||||||
|
- `AdminObjectView.visibility` (`admin_objects.rs:43`, currently `pub visibility: String`): add above it `#[schema(value_type = domain::Visibility)]`.
|
||||||
|
- `ObjectCreateRequest.visibility` (`admin_objects.rs:165-166`): **remove** the `#[schema(value_type = String)]` line so the field (`pub visibility: Visibility`) emits the enum.
|
||||||
|
- `VisibilityRequest.visibility` (`crates/api/src/admin.rs`, field is `pub visibility: Visibility`): if it has a `#[schema(value_type = String)]` override, **remove** it so it emits the enum. (Check — it may or may not have one.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: #29 — `data_type` + `authority_kind` enums.** In `crates/api/src/admin_objects.rs`, `FieldDefinitionView` (~lines 360-366):
|
||||||
|
- `data_type` (line 363): add `#[schema(value_type = domain::DataType)]`.
|
||||||
|
- `authority_kind` (line 365): add `#[schema(value_type = Option<domain::AuthorityKind>)]`.
|
||||||
|
- The `NewFieldDefinitionRequest` (~lines 374-377) `data_type`/`authority_kind` are request inputs parsed as free strings by the handler — **leave these as `String`** (typing them would force handler conversion; out of scope, and the create form posts plain strings).
|
||||||
|
|
||||||
|
- [ ] **Step 4: #29 — authority `kind`.** In `crates/api/src/admin_authorities.rs`, `AuthorityView.kind` (line 23, `pub kind: String`): add `#[schema(value_type = domain::AuthorityKind)]`. Leave `NewAuthorityRequest.kind` (line 31) as `String` (request input parsed via `from_db`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Register the domain enums as OpenAPI components.** In `crates/api/src/openapi.rs` `components(schemas(...))`, add:
|
||||||
|
```rust
|
||||||
|
domain::Visibility,
|
||||||
|
domain::AuthorityKind,
|
||||||
|
domain::DataType,
|
||||||
|
```
|
||||||
|
(utoipa generates `$ref`s to these from the `value_type` annotations; they must be registered. The `api` crate already depends on `domain`.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build + backend tests.**
|
||||||
|
```bash
|
||||||
|
cargo +nightly fmt
|
||||||
|
cargo clippy -p api --all-targets
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api
|
||||||
|
```
|
||||||
|
All green (serialized values are unchanged — `visibility` still serializes "draft" etc., `data_type` still "text"/"localized_text").
|
||||||
|
|
||||||
|
- [ ] **Step 7: Regenerate the typed client.**
|
||||||
|
```bash
|
||||||
|
cargo build -p server
|
||||||
|
lsof -ti :8080 | xargs kill 2>/dev/null
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey ./target/debug/server &
|
||||||
|
SERVER_PID=$!
|
||||||
|
sleep 2
|
||||||
|
( cd web && pnpm gen:api )
|
||||||
|
kill "$SERVER_PID"
|
||||||
|
```
|
||||||
|
Verify the generated types:
|
||||||
|
```bash
|
||||||
|
grep -n "Visibility:\|AuthorityKind:\|DataType:" web/src/api/schema.d.ts
|
||||||
|
grep -n "additionalProperties\|\[key: string\]: unknown" web/src/api/schema.d.ts | head
|
||||||
|
```
|
||||||
|
Expect `Visibility: "draft" | "internal" | "public"`, `AuthorityKind: "person" | "organisation" | "place"`, `DataType: "text" | "localized_text" | ...`, and `AdminObjectView.fields` as `{ [key: string]: unknown }`. Then `cd web && pnpm typecheck` — it may now report errors at the cast sites (expected; Task 3 fixes them) OR pass (casts on a now-compatible type are just redundant). Either way, do NOT edit web source in this task beyond the regenerated `schema.d.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add crates/api web/src/api/schema.d.ts
|
||||||
|
git commit -m "feat(api): enum-typed visibility/data_type/kind + open-map fields in OpenAPI (#24 #29)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Frontend — drop the now-redundant casts
|
||||||
|
|
||||||
|
**Files:** `web/src/objects/object-detail.tsx`, `web/src/objects/object-form.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/objects/publish-control.tsx` (+ check `visibility-badge.tsx`, `field-input.tsx`). Plus any local `Visibility` type alias.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove the `fields` casts (#24).** `fields` is now `{ [key: string]: unknown }`:
|
||||||
|
- `object-detail.tsx:55`: `Object.entries(object.fields as Record<string, unknown>)` → `Object.entries(object.fields)`.
|
||||||
|
- `object-form.tsx:181`: `Object.entries(value as Record<string, unknown>)` → `Object.entries(value)` (only if `value` is the typed `fields`; if `value` is a generic RHF value, the cast may still be needed — verify the type and remove only if redundant).
|
||||||
|
- `object-edit-form.tsx:37`: `fields: object.fields as Record<string, unknown>` → `fields: object.fields` (if the target type accepts the open map; otherwise leave).
|
||||||
|
Remove a cast only when the typecheck confirms it's now redundant. Keep the code `any`-free.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove the `visibility` cast (#29).** `publish-control.tsx:26`: `const current = object.visibility as Visibility;` → `const current = object.visibility;` (it's now the `"draft" | "internal" | "public"` union). If a local `type Visibility = ...` alias exists and is now identical to the schema union, prefer referencing `components["schemas"]["Visibility"]` or keep the alias if it's used as a shared name — but drop the cast. Check `visibility-badge.tsx`: if its prop is `visibility: string`, you may tighten it to the union or leave it (a union is assignable to `string`); do NOT introduce errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `data_type` (#29).** `field-input.tsx` switches on `data_type` — now a union. No cast was present; confirm the switch still typechecks (a union improves exhaustiveness). If there's a `data_type as ...` cast anywhere, remove it.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify.**
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
|
||||||
|
```
|
||||||
|
All green; no `any`/`@ts-ignore` introduced; bundle ≤150 KB. Grep to confirm the casts are gone:
|
||||||
|
```bash
|
||||||
|
grep -rn "as Record<string, unknown>\|as Visibility" web/src/objects | grep -v ".test."
|
||||||
|
```
|
||||||
|
(Test-file `as Record<string, unknown>` defaults may remain — they're test scaffolding, not contract casts; leaving them is fine.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add web
|
||||||
|
git commit -m "refactor(web): drop redundant fields/visibility casts now the client is typed (#24 #29)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1:** `cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (report bundle gz).
|
||||||
|
- [ ] **Step 2:**
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p domain
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo +nightly fmt --check
|
||||||
|
```
|
||||||
|
- [ ] **Step 3:** i18n parity check (unchanged keys, but run it); `git grep -in 'biggus\|dickus' -- crates web/src` → none.
|
||||||
|
- [ ] **Step 4:** Confirm acceptance: OpenAPI `fields` has `additionalProperties`; `visibility`/`data_type`/`kind` are string enums in `schema.d.ts`; the `as Record<string, unknown>`/`as Visibility` contract casts are gone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
- **Spec coverage:** #3 decided (Option A, documented + closed) → this plan's architecture; #24 (open-map fields) → T2 Step 1 + T3 Step 1; #29 (visibility/data_type/kind enums) → T1 + T2 Steps 2-5 + T3 Steps 2-3. ✓
|
||||||
|
- **Placeholder scan:** none — exact files/lines/annotations given; the "remove cast only if typecheck confirms redundant" notes are correct verification guards (the generated types determine redundancy).
|
||||||
|
- **Type consistency:** `DataType` uses `snake_case` to match `FieldType::kind_str` (`localized_text`); `value_type = domain::X` references match the enums registered in `openapi.rs` components; runtime serialization is unchanged (backend tests prove it), so only the schema/TS types tighten.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Request-side enums (`NewFieldDefinitionRequest.data_type`/`authority_kind`, `NewAuthorityRequest.kind`) intentionally stay `String` — the handlers parse/validate them; typing them is a separate, larger change (would need handler conversion) and isn't required by #24/#29.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Tier 4 Hardening — Batch 1 (#1, #2, #21) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||||
|
|
||||||
|
**Goal:** The mechanical, well-specified hardening items — graceful HTTP shutdown (#1), configurable DB pool size (#2), and audit logging for vocabulary/term/authority creation (#21). (The design-heavy Tier 4 items #20/#5/#7 are handled separately.)
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (axum 0.8, sqlx, tokio, anyhow). Backend-only.
|
||||||
|
|
||||||
|
**Conventions:** nightly fmt; clippy `-D warnings`; no codename. Test infra: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev`, `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey` (`#[sqlx::test]` provisions its own DB).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: #1 — graceful shutdown
|
||||||
|
|
||||||
|
**Files:** `crates/server/src/lib.rs`, `crates/server/Cargo.toml` (tokio `signal` feature if missing).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Ensure tokio `signal` feature.** Check `crates/server/Cargo.toml`'s `tokio` dependency features include `"signal"`. If the workspace `tokio` is `features = ["full"]` it's already included; otherwise add `"signal"` (and `"macros"`/`"rt-multi-thread"` if not already). Verify with `cargo build -p server`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a shutdown-signal future** in `crates/server/src/lib.rs` (above `serve`):
|
||||||
|
```rust
|
||||||
|
/// Resolves when the process receives SIGINT (Ctrl-C) or SIGTERM, so the server can
|
||||||
|
/// drain in-flight requests before exiting.
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
tokio::signal::ctrl_c()
|
||||||
|
.await
|
||||||
|
.expect("install Ctrl-C handler");
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let terminate = async {
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("install SIGTERM handler")
|
||||||
|
.recv()
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let terminate = std::future::pending::<()>();
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {},
|
||||||
|
_ = terminate => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("shutdown signal received; draining");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire it into `serve`.** Change the `axum::serve(...)` call:
|
||||||
|
```rust
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await
|
||||||
|
.context("running the HTTP server")?;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify.** `cargo +nightly fmt`; `cargo clippy -p server --all-targets`; `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p server` (the existing `serve.rs` smoke test still passes — it aborts the handle, which is unaffected). Commit:
|
||||||
|
```bash
|
||||||
|
git add crates/server
|
||||||
|
git commit -m "feat(server): graceful shutdown on SIGINT/SIGTERM (#1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: #2 — configurable DB pool size
|
||||||
|
|
||||||
|
**Files:** `crates/db/src/lib.rs`, `crates/server/src/config.rs`, `crates/server/src/lib.rs`.
|
||||||
|
|
||||||
|
`Db::connect` currently hardcodes `.max_connections(5)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Parameterize `Db::connect`.** In `crates/db/src/lib.rs`:
|
||||||
|
```rust
|
||||||
|
/// Connect to the database at `database_url`, opening a connection pool with at most
|
||||||
|
/// `max_connections` connections.
|
||||||
|
pub async fn connect(database_url: &str, max_connections: u32) -> Result<Self, sqlx::Error> {
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(max_connections)
|
||||||
|
.connect(database_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Self { pool })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the config knob.** In `crates/server/src/config.rs`, add a field to `Config`:
|
||||||
|
```rust
|
||||||
|
/// Maximum size of the PostgreSQL connection pool.
|
||||||
|
#[arg(long = "db-max-connections", env = "DB_MAX_CONNECTIONS", default_value_t = 5)]
|
||||||
|
pub db_max_connections: u32,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Thread it through the two `Db::connect` call sites** in `crates/server/src/lib.rs`:
|
||||||
|
- In `run`: `Db::connect(&config.database_url, config.db_max_connections)`.
|
||||||
|
- In `create_user` (the CLI one-shot — it has only `database_url: &str`, no `Config`): pass a small fixed default, `Db::connect(database_url, 2)` (a one-shot CLI needs minimal connections), and add a brief comment.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify.** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets`; `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p server`. Confirm `cargo run -p server -- --help` shows the new `--db-max-connections` flag (optional). Commit:
|
||||||
|
```bash
|
||||||
|
git add crates/db crates/server
|
||||||
|
git commit -m "feat(server): configurable DB pool size via --db-max-connections/DB_MAX_CONNECTIONS (#2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: #21 — audit vocabulary/term/authority creation
|
||||||
|
|
||||||
|
**Files:** `crates/db/src/vocab.rs`, `crates/db/src/authority.rs`, `crates/api/src/admin_vocab.rs`, `crates/api/src/admin_authorities.rs`; Test in `crates/api/tests/admin_catalog.rs`.
|
||||||
|
|
||||||
|
The three admin create paths (`create_vocabulary`, `add_term`, `create_authority`) take no `AuditActor` and write no audit entry. The catalogue object writes do — **`db::catalog::create_object` is the template**: it takes `actor: AuditActor` and calls `audit::record(&mut *conn, &NewAuditEvent { actor, action: AuditAction::Created, entity_type, entity_id, ... })` inside the same transaction. READ `create_object` (`crates/db/src/catalog.rs`) and `audit::record` / `NewAuditEvent` (`crates/db/src/audit.rs`, `domain::NewAuditEvent`) first to copy the exact shape.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `actor` + audit to the db functions.** Each must run the insert **and** the audit record in one transaction (so they're atomic), mirroring `create_object`:
|
||||||
|
- `db::vocab::create_vocabulary` — currently `(executor: E, key: &str)`. Change to `(conn: &mut sqlx::PgConnection, actor: AuditActor, key: &str)` (tx-connection like `add_term`), insert the vocabulary, then `audit::record(&mut *conn, &NewAuditEvent { actor, action: Created, entity_type: "vocabulary", entity_id: <new vocab id>, ... })`. Return the `Vocabulary` as before.
|
||||||
|
- `db::vocab::add_term` — currently `(conn: &mut PgConnection, new: &NewTerm)`. Add `actor: AuditActor`; after inserting the term, record an audit entry (`entity_type: "term"`, `entity_id: <term id>`).
|
||||||
|
- `db::authority::create_authority` — add `actor: AuditActor`; record (`entity_type: "authority"`, `entity_id: <authority id>`).
|
||||||
|
Match `create_object`'s `NewAuditEvent` field names exactly (e.g. `changes`/`metadata` may be empty/None — copy whatever `create_object` passes for a creation with no field diff).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Thread the actor through the handlers.** In `crates/api/src/admin_vocab.rs` (`create_vocabulary`, `add_term`) and `crates/api/src/admin_authorities.rs` (`create_authority`):
|
||||||
|
- Change `_auth: Authorized<EditCatalogue>` → `auth: Authorized<EditCatalogue>`.
|
||||||
|
- Build the actor as the object handlers do: `AuditActor::User(auth.user.id.to_uuid())`. To avoid duplicating the helper, either make `admin_objects::actor` `pub(crate)` and import it, or inline `AuditActor::User(auth.user.id.to_uuid())` at each site (it's a one-liner — pick the cleaner option; if you make the helper shared, take `&AuthUser`).
|
||||||
|
- `create_vocabulary` handler currently calls `db::vocab::create_vocabulary(state.db.pool(), &req.key)` on the **pool** — change it to open a transaction (`let mut tx = state.db.pool().begin().await...`), call the new `create_vocabulary(&mut tx, actor, &req.key)`, then `tx.commit()` (like `add_term`'s handler already does). `add_term`/`create_authority` handlers already use a tx — just pass the actor.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test** — add to `crates/api/tests/admin_catalog.rs` (it already seeds an editor + logs in). After creating a vocabulary (or term/authority) via the API, assert an audit row exists attributing the user. Use `db::audit::history_for` (or a direct `SELECT` on `audit_log`) to find the entry — read the file for how existing tests inspect audit rows (the object tests likely already do this; mirror them). Minimal: create a vocabulary, then query `audit_log` for `entity_type='vocabulary'` with the created id and assert `actor_kind='user'` + the right `actor_id`. Name it e.g. `creating_a_vocabulary_writes_an_audit_entry`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify.** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets`; `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p db`. All green. Commit:
|
||||||
|
```bash
|
||||||
|
git add crates/db crates/api
|
||||||
|
git commit -m "feat: audit vocabulary/term/authority creation, attributing the acting user (#21)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1:** `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace` — all green.
|
||||||
|
- [ ] **Step 2:** `cargo clippy --workspace --all-targets` and `cargo +nightly fmt --check` — clean.
|
||||||
|
- [ ] **Step 3:** `git grep -in 'biggus\|dickus' -- crates` → none.
|
||||||
|
- [ ] **Step 4:** Confirm `Cargo.lock` is committed if any dependency/feature changed (e.g. tokio `signal` feature does not add a new lockfile entry, but verify `git status` is clean after the commits — no dangling `M Cargo.lock`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
- **Spec coverage:** #1 (graceful shutdown) → T1; #2 (configurable pool) → T2; #21 (audit 3 admin creates) → T3. ✓
|
||||||
|
- **Placeholder scan:** none — concrete code for #1/#2; #21 points at `create_object`/`audit::record` as the exact template to mirror (the audit-event field names live there and must match, so copying beats guessing).
|
||||||
|
- **Type consistency:** `Db::connect(url, max: u32)` updated at both call sites (run + create_user); `db_max_connections: u32` matches `max_connections(u32)`; the three db create fns gain `actor: AuditActor` and the handlers pass `AuditActor::User(auth.user.id.to_uuid())` consistently with `admin_objects::actor`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- #21 keeps within the current audit model (`AuditAction::Created` + non-null `entity_type`/`entity_id`) — no schema change needed (the auth-event model extension is the separate #7).
|
||||||
|
- Watch the `Cargo.lock`: if the tokio `signal` feature pulls a new transitive crate, stage the root `Cargo.lock` in the same commit (don't leave it dangling).
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
# Follow-ups Batch (#38, #28, #41, #26) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||||
|
|
||||||
|
**Goal:** Four small, well-specified follow-ups: enum-type `SearchHitView.visibility` (#38); carry the offending field in the `set_fields` 422 so the UI can highlight it (#28); normalize `localized_text` to the default language on save (#41); pin the pnpm version (#26).
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (axum, utoipa), React + TS, react-hook-form, Vitest + RTL + MSW.
|
||||||
|
|
||||||
|
**Conventions:** nightly fmt; clippy `-D warnings`; no `any`/`eslint-disable`/`@ts-ignore`; en/sv parity; codename ban; bundle ≤150 KB gz. Test infra: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev`, `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey`. cargo from repo root; web from `web/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Backend — `SearchHitView.visibility` enum (#38) + `set_fields` field-level 422 (#28)
|
||||||
|
|
||||||
|
**Files:** Modify `crates/api/src/admin_search.rs`, `crates/api/src/admin_objects.rs`, `crates/api/src/openapi.rs`; Test `crates/api/tests/admin_objects.rs`; Regenerate `web/src/api/schema.d.ts`.
|
||||||
|
|
||||||
|
### #38 — enum-type the search hit visibility
|
||||||
|
- [ ] **Step 1:** In `crates/api/src/admin_search.rs`, `SearchHitView.visibility` (line ~31, `pub visibility: String`): add the attribute above it:
|
||||||
|
```rust
|
||||||
|
#[schema(value_type = domain::Visibility)]
|
||||||
|
pub visibility: String,
|
||||||
|
```
|
||||||
|
(`domain::Visibility` already derives `ToSchema` and is registered in `openapi.rs` from #29 — no further registration needed.)
|
||||||
|
|
||||||
|
### #28 — carry the offending field in the 422
|
||||||
|
The db `FieldError` already names the field (`UnknownField(String)`, `TypeMismatch { field, .. }`, `Unresolved { field, .. }`). Surface it.
|
||||||
|
|
||||||
|
- [ ] **Step 2:** In `crates/api/src/admin_objects.rs`, add a response DTO near the other views:
|
||||||
|
```rust
|
||||||
|
/// Field-level rejection detail for `set_fields`, so the UI can highlight the field.
|
||||||
|
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||||
|
pub(crate) struct FieldErrorView {
|
||||||
|
/// The flexible-field key that was rejected.
|
||||||
|
pub field: String,
|
||||||
|
/// Machine code: "unknown" | "type_mismatch" | "unresolved".
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:** Change the `set_fields` handler to return a body on the field-error 422s. Its signature is `-> Result<StatusCode, StatusCode>`; change to `-> axum::response::Response` and build responses (import `axum::response::IntoResponse`):
|
||||||
|
```rust
|
||||||
|
) -> axum::response::Response {
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
|
||||||
|
let Ok(object_id) = id.parse::<ObjectId>() else {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tx = match state.db.pool().begin().await {
|
||||||
|
Ok(tx) => tx,
|
||||||
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result =
|
||||||
|
db::catalog::set_object_fields(&mut tx, actor(&auth.user), object_id, &values).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
if tx.commit().await.is_err() {
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
}
|
||||||
|
reindex(&state, object_id).await;
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
Err(db::catalog::FieldError::ObjectNotFound) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
Err(db::catalog::FieldError::Db(_)) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
Err(db::catalog::FieldError::UnknownField(field)) => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
Json(FieldErrorView { field, code: "unknown".to_owned() }),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(db::catalog::FieldError::TypeMismatch { field, .. }) => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
Json(FieldErrorView { field, code: "type_mismatch".to_owned() }),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(db::catalog::FieldError::Unresolved { field, .. }) => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
Json(FieldErrorView { field, code: "unresolved".to_owned() }),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Update the `#[utoipa::path(...)]` on `set_fields`: the 422 response now has a body — change/add `(status = 422, body = FieldErrorView, description = "A field was rejected")` in its `responses(...)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4:** Register `admin_objects::FieldErrorView` in `crates/api/src/openapi.rs` `components(schemas(...))`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Test** — add to `crates/api/tests/admin_objects.rs` (reuse its harness: seed editor, login, create an object). Create an object, then PUT `/api/admin/objects/{id}/fields` with an **unknown** field key → assert `422` and the body `{ field: "<that key>", code: "unknown" }`. (Mirror an existing set-fields test if present; if a field-definition is needed for a type_mismatch case, the `unknown` case needs none — simplest.) Read the file for the exact request/parse helpers.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build + backend tests:**
|
||||||
|
```bash
|
||||||
|
cargo +nightly fmt
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api
|
||||||
|
```
|
||||||
|
All green (existing set_fields tests still pass — success path still 204; the failure path now carries a body but the status is unchanged at 422).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Regenerate client:**
|
||||||
|
```bash
|
||||||
|
cargo build -p server
|
||||||
|
lsof -ti :8080 | xargs kill 2>/dev/null
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey ./target/debug/server &
|
||||||
|
SERVER_PID=$!
|
||||||
|
sleep 2
|
||||||
|
( cd web && pnpm gen:api )
|
||||||
|
kill "$SERVER_PID"
|
||||||
|
grep -n "FieldErrorView" web/src/api/schema.d.ts
|
||||||
|
# confirm SearchHitView.visibility now references the Visibility union:
|
||||||
|
grep -n "SearchHitView" web/src/api/schema.d.ts
|
||||||
|
```
|
||||||
|
`FieldErrorView` present; `SearchHitView.visibility` → `components["schemas"]["Visibility"]`. `cd web && pnpm typecheck` clean. Diff additive.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit:**
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add crates/api web/src/api/schema.d.ts
|
||||||
|
git commit -m "feat(api): field-level set_fields 422 body (#28); enum-type SearchHitView.visibility (#38)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Frontend — surface the rejected field & highlight it (#28)
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/api/queries.ts`, `web/src/objects/object-form.tsx`, `web/src/objects/object-new-page.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/i18n/{en,sv}.json`; Test `web/src/objects/object-form.test.tsx` or the relevant existing object test.
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n** — add `form.fieldRejected` to BOTH `en.json` and `sv.json` (interpolated):
|
||||||
|
- en `form`: `"fieldRejected": "The field \"{{field}}\" was rejected — check its value"`
|
||||||
|
- sv `form`: `"fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet"`
|
||||||
|
|
||||||
|
- [ ] **Step 2: A typed rejection in `useSetFields`** — in `web/src/api/queries.ts`, add near the other errors:
|
||||||
|
```ts
|
||||||
|
export class FieldRejection extends Error {
|
||||||
|
constructor(public readonly field: string, public readonly code: string) {
|
||||||
|
super(`field rejected: ${field}`);
|
||||||
|
this.name = "FieldRejection";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Update `useSetFields`'s `mutationFn` to parse the 422 body and throw `FieldRejection`:
|
||||||
|
```ts
|
||||||
|
mutationFn: async ({ id, fields }: { id: string; fields: Record<string, unknown> }) => {
|
||||||
|
const { response, error } = await api.PUT("/api/admin/objects/{id}/fields", {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: fields as Record<string, never>,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 204) return;
|
||||||
|
|
||||||
|
if (response.status === 422 && error && typeof error === "object" && "field" in error) {
|
||||||
|
const detail = error as { field: string; code: string };
|
||||||
|
throw new FieldRejection(detail.field, detail.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("set fields failed");
|
||||||
|
},
|
||||||
|
```
|
||||||
|
(openapi-fetch puts the 422 body in `error` because the operation declares a 422 body schema. If `error` typing is awkward, narrow defensively as above — no `any`.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Thread a field-error into the form** — `object-form.tsx` owns the react-hook-form instance. Add an optional prop `fieldErrorKey?: string | null` and, via `useEffect`, set/clear the RHF error so the field highlights:
|
||||||
|
```tsx
|
||||||
|
// in the ObjectForm props type:
|
||||||
|
fieldErrorKey?: string | null;
|
||||||
|
// inside the component (form is the useForm instance; t available):
|
||||||
|
useEffect(() => {
|
||||||
|
if (fieldErrorKey) {
|
||||||
|
form.setError(`fields.${fieldErrorKey}` as never, {
|
||||||
|
type: "server",
|
||||||
|
message: t("form.fieldRejected", { field: fieldErrorKey }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [fieldErrorKey, form, t]);
|
||||||
|
```
|
||||||
|
(The `as never` is to satisfy RHF's path typing for a dynamic flexible-field path; if a cleaner typed path is available without `any`, use it — `as never` is acceptable here and is NOT `as any`. Confirm lint accepts it; if `react-hooks/exhaustive-deps` complains, include the listed deps.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Parent catch sets the field key** — in `object-new-page.tsx` and `object-edit-form.tsx`, the `catch` currently does `setError(t("form.rejected"))`. Capture the rejected field too:
|
||||||
|
- Add state `const [fieldErrorKey, setFieldErrorKey] = useState<string | null>(null);`
|
||||||
|
- In the catch: `if (e instanceof FieldRejection) { setFieldErrorKey(e.field); setError(t("form.fieldRejected", { field: e.field })); } else { setError(t("form.rejected")); }` (import `FieldRejection` from `../api/queries`).
|
||||||
|
- Pass `fieldErrorKey={fieldErrorKey}` to `<ObjectForm>`.
|
||||||
|
- Clear `setFieldErrorKey(null)` at the top of `onSubmit` (alongside `setError(null)`).
|
||||||
|
(For `object-edit-form.tsx`, which also reads a `location.state.fieldsError` flag, keep that path but layer the new typed handling on top.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Test** — add a test (in the object form/new-page test file, MSW) where PUT `/api/admin/objects/:id/fields` returns `422` with `{ field: "dimensions", code: "type_mismatch" }`. Submit the form; assert the field-rejected message appears (`/dimensions/i` + "rejected") and, if practical, that the field's input is marked invalid (`aria-invalid` or an error message near it). Use the existing object-form test setup; read it for the render/submit pattern.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify + commit:**
|
||||||
|
```bash
|
||||||
|
cd web && pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add web
|
||||||
|
git commit -m "feat(web): highlight the offending field on a set_fields 422 (#28)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Frontend — visibility-badge typing (#38) + localized_text normalize-on-save (#41)
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/objects/visibility-badge.tsx`, `web/src/objects/object-form.tsx`; Test the object-form/field tests.
|
||||||
|
|
||||||
|
### #38 — tighten the VisibilityBadge prop
|
||||||
|
- [ ] **Step 1:** `web/src/objects/visibility-badge.tsx` — change the prop from `string` to the schema union (now that all callers pass it, incl. search hits after Task 1):
|
||||||
|
```tsx
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
|
||||||
|
type Visibility = components["schemas"]["Visibility"];
|
||||||
|
|
||||||
|
export function VisibilityBadge({ visibility }: { visibility: Visibility }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={STYLES[visibility] ?? ""}>
|
||||||
|
{t(`visibility.${visibility}`)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Run `pnpm typecheck` — every caller (`object-list`, `object-detail`, `search-result-row`) now passes the union (object/search hit `visibility` are the union post-#29/#38). Fix any caller that still has a widened `string` (there should be none).
|
||||||
|
|
||||||
|
### #41 — normalize localized_text to the default language on save
|
||||||
|
The edit path seeds `defaultValues.fields` from `object.fields` verbatim, so a `localized_text` value authored under another language keeps that key. Normalize in `pruneFields` so only the default-language key is saved.
|
||||||
|
|
||||||
|
- [ ] **Step 2:** In `web/src/objects/object-form.tsx`:
|
||||||
|
- Add `import { useConfig } from "../config/config-context";` and inside the component: `const { default_language } = useConfig();`.
|
||||||
|
- Compute the set of localized_text field keys from the loaded definitions:
|
||||||
|
```tsx
|
||||||
|
const localizedTextKeys = new Set(
|
||||||
|
(definitions ?? []).filter((d) => d.data_type === "localized_text").map((d) => d.key),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- Pass both into `pruneFields` at its call site (`const fields = pruneFields(data.fields, localizedTextKeys, default_language);`).
|
||||||
|
- Update `pruneFields` to accept them and, for a localized_text key, keep only the default-language sub-value:
|
||||||
|
```tsx
|
||||||
|
function pruneFields(
|
||||||
|
fields: Record<string, unknown>,
|
||||||
|
localizedTextKeys: Set<string>,
|
||||||
|
defaultLang: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
|
if (value === undefined || value === null || value === "") continue;
|
||||||
|
|
||||||
|
if (typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
const map = value as Record<string, unknown>;
|
||||||
|
// Single-language authoring: a localized_text value keeps only the default lang.
|
||||||
|
const entries = localizedTextKeys.has(key)
|
||||||
|
? Object.entries(map).filter(([lang]) => lang === defaultLang)
|
||||||
|
: Object.entries(map);
|
||||||
|
|
||||||
|
const inner = Object.fromEntries(
|
||||||
|
entries.filter(([, v]) => v !== undefined && v !== null && v !== ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Object.keys(inner).length > 0) out[key] = inner;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test** — add/extend a test: an object whose `localized_text` field value is `{ en: "Old", sv: "Ny" }`, edited on an `sv`-default instance, submits `fields` containing only `{ <key>: { sv: "Ny" } }` (the `en` key stripped). Use the object-form test harness (the `definitions` fixture has a `localized_text` field — `title_ml`). Assert the pruned payload via the submit handler / the PUT body.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify + commit:**
|
||||||
|
```bash
|
||||||
|
cd web && pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add web
|
||||||
|
git commit -m "fix(web): VisibilityBadge typed to the union (#38); normalize localized_text to default language on save (#41)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Pin pnpm (#26) + verification
|
||||||
|
|
||||||
|
**Files:** Modify `web/package.json`, `.gitea/workflows/ci.yaml`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Pin pnpm** — add a `packageManager` field to `web/package.json` matching the dev/CI version. The local pnpm is `11.5.1`; CI's `pnpm/action-setup` is pinned to `9` — a mismatch. Unify on the local version:
|
||||||
|
- In `web/package.json`, add (top level): `"packageManager": "pnpm@11.5.1"`.
|
||||||
|
- In `.gitea/workflows/ci.yaml`, change the `pnpm/action-setup@v4` `version: 9` → `version: 11` (matching the major).
|
||||||
|
- [ ] **Step 2: Confirm the lockfile is consistent** — run `cd web && pnpm install --frozen-lockfile`. If it passes, the committed `pnpm-lock.yaml` is compatible — done. If it FAILS (lockfile format/version mismatch from the pnpm-9→11 change), run `pnpm install` once to update the lockfile, confirm only the lockfile changed (`git status`), and include `web/pnpm-lock.yaml` in the commit. Report which case occurred.
|
||||||
|
- [ ] **Step 3: Commit:**
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add web/package.json .gitea/workflows/ci.yaml web/pnpm-lock.yaml
|
||||||
|
git commit -m "build(web): pin pnpm via packageManager + align CI (#26)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Final verification
|
||||||
|
- [ ] **Step 4: i18n parity** —
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const k=o=>Object.entries(o).flatMap(([K,v])=>typeof v==='object'?k(v).map(s=>K+'.'+s):[K]);const ka=k(a).sort(),kb=k(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH '+JSON.stringify({onlyEn:ka.filter(x=>!kb.includes(x)),onlySv:kb.filter(x=>!ka.includes(x))}))"
|
||||||
|
```
|
||||||
|
Expected `PARITY OK`.
|
||||||
|
- [ ] **Step 5: Full suites** —
|
||||||
|
```bash
|
||||||
|
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets && cargo +nightly fmt --check
|
||||||
|
```
|
||||||
|
All green; bundle ≤150 KB; clippy/fmt clean.
|
||||||
|
- [ ] **Step 6:** `git grep -in 'biggus\|dickus' -- crates web/src` → none.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
- **Spec coverage:** #38 (search visibility enum → T1 backend + T3 prop tighten); #28 (422 field body → T1 backend, T2 FE highlight); #41 (localized_text normalize → T3); #26 (pin pnpm → T4). ✓
|
||||||
|
- **Placeholder scan:** none — concrete code; the "read the test harness" notes are verification steps against named files. The `as never` in T2 Step 3 is a typed-RHF-path escape (NOT `as any`/ts-ignore) and is flagged for lint confirmation.
|
||||||
|
- **Type consistency:** `FieldErrorView { field, code }` (Rust) ↔ `components["schemas"]["FieldErrorView"]` (the 422 body openapi-fetch surfaces as `error`) ↔ `FieldRejection{field,code}`; `SearchHitView.visibility` union flows into the tightened `VisibilityBadge` prop; `pruneFields` new signature `(fields, localizedTextKeys, defaultLang)` updated at its single call site.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- #28 changes the `set_fields` handler return type from `Result<StatusCode, StatusCode>` to `Response`; the success status (204) and the field-error status (422) are unchanged — only a body is added to the 422, so existing status-only tests still pass.
|
||||||
|
- #26: if `pnpm install --frozen-lockfile` forces a lockfile regen, that's expected and the regenerated `pnpm-lock.yaml` is committed; flag if dependency versions shifted.
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
# Instance Locale + Single-Language Content Authoring Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||||
|
|
||||||
|
**Goal:** Drive instance UI/content language + display timezone from environment variables (no settings table), surface them to the SPA via a public `GET /api/config`, default the UI language from it, and collapse content authoring (`LabelEditor` + `LocalizedText` field input) to a single language — **without touching the multilingual content schema** (dormant, re-enabled by UI alone).
|
||||||
|
|
||||||
|
**Architecture:** Two `server::Config` env knobs (`DEFAULT_LANGUAGE`, `DEFAULT_TIMEZONE`) flow into `AppState` and a public `ConfigView` endpoint. A frontend `ConfigProvider` fetches it once, sets the i18n language (when no per-browser override), and feeds the default language to the simplified content inputs. Storage stays UTC; timezone is exposed but has no frontend formatter yet (no timestamp displays exist — deferred to its first consumer).
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (axum, utoipa, clap), React + TS, react-i18next, TanStack Query, Vitest + RTL + MSW.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-05-instance-locale-and-content-authoring-design.md`
|
||||||
|
|
||||||
|
**Conventions:** nightly fmt; clippy `-D warnings`; no `any`/`eslint-disable`/`@ts-ignore`; en/sv i18n parity; codename ban; bundle ≤150 KB gz. Test infra: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev`, `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey`. cargo from repo root; web from `web/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Backend — config knobs + `AppState` + public `GET /api/config` + regen client
|
||||||
|
|
||||||
|
**Files:** Modify `crates/server/src/config.rs`, `crates/server/src/lib.rs`, `crates/api/src/lib.rs`, `crates/api/src/openapi.rs`; Create `crates/api/src/config.rs`; Modify all `AppState { … }` construction sites (server + api test harnesses); Test `crates/api/tests/config.rs`; Regenerate `web/src/api/schema.d.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Config knobs.** In `crates/server/src/config.rs`, add to `Config` (clap derive, matching `app_name`'s style):
|
||||||
|
```rust
|
||||||
|
/// Default UI + content-authoring language for this instance (i18n key, e.g. "sv").
|
||||||
|
#[arg(long = "default-language", env = "DEFAULT_LANGUAGE", default_value = "sv")]
|
||||||
|
pub default_language: String,
|
||||||
|
|
||||||
|
/// Default display timezone (IANA name, e.g. "Europe/Stockholm"). Storage stays UTC;
|
||||||
|
/// this is a display hint surfaced to clients (and, later, server-side renderers).
|
||||||
|
#[arg(long = "default-timezone", env = "DEFAULT_TIMEZONE", default_value = "Europe/Stockholm")]
|
||||||
|
pub default_timezone: String,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `AppState` fields.** In `crates/api/src/lib.rs`, add to `pub struct AppState`:
|
||||||
|
```rust
|
||||||
|
/// Instance default UI/content language (from config).
|
||||||
|
pub default_language: String,
|
||||||
|
/// Instance default display timezone, IANA name (from config). Storage stays UTC.
|
||||||
|
pub default_timezone: String,
|
||||||
|
```
|
||||||
|
In `crates/server/src/lib.rs` `run`, populate them when building `AppState`:
|
||||||
|
```rust
|
||||||
|
default_language: config.default_language,
|
||||||
|
default_timezone: config.default_timezone,
|
||||||
|
```
|
||||||
|
(place after `app_name: config.app_name,` — note these are moves; `config` fields are disjoint.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update every other `AppState { … }` site.** Run `grep -rn "AppState {" crates/` — besides `crates/api/src/lib.rs` (the struct def) and `server/src/lib.rs` (done above), there are ~9 test `state(...)` helpers (`crates/server/tests/serve.rs`, `crates/api/tests/{admin,admin_objects,admin_search,public,reindex,admin_catalog,admin_fields,health}.rs`). Add to each literal:
|
||||||
|
```rust
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
|
```
|
||||||
|
(The build will fail to compile until all are updated — that's the checklist.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write the failing API test** — create `crates/api/tests/config.rs`:
|
||||||
|
```rust
|
||||||
|
use api::{AppState, build_app};
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
fn state(pool: PgPool) -> AppState {
|
||||||
|
AppState {
|
||||||
|
db: db::Db::from_pool(pool),
|
||||||
|
app_name: "Test Museum".into(),
|
||||||
|
cookie_secure: false,
|
||||||
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn config_is_public_and_reflects_state(pool: PgPool) {
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let resp = app
|
||||||
|
.oneshot(Request::builder().uri("/api/config").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
assert_eq!(body["app_name"], "Test Museum");
|
||||||
|
assert_eq!(body["default_language"], "sv");
|
||||||
|
assert_eq!(body["default_timezone"], "Europe/Stockholm");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run → fails** (`/api/config` 404): `cargo test -p api --test config`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Implement the endpoint** — create `crates/api/src/config.rs` (mirror `health.rs`):
|
||||||
|
```rust
|
||||||
|
use axum::{Json, Router, extract::State, routing::get};
|
||||||
|
use serde::Serialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Public, non-sensitive instance configuration the SPA needs before login.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct ConfigView {
|
||||||
|
/// User-facing product name.
|
||||||
|
pub app_name: String,
|
||||||
|
/// Default UI/content language (i18n key, e.g. "sv").
|
||||||
|
pub default_language: String,
|
||||||
|
/// Default display timezone (IANA name). Storage is UTC; this is a display hint.
|
||||||
|
pub default_timezone: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/config", responses((status = 200, body = ConfigView)))]
|
||||||
|
pub(crate) async fn get_config(State(state): State<AppState>) -> Json<ConfigView> {
|
||||||
|
Json(ConfigView {
|
||||||
|
app_name: state.app_name.clone(),
|
||||||
|
default_language: state.default_language.clone(),
|
||||||
|
default_timezone: state.default_timezone.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn routes() -> Router<AppState> {
|
||||||
|
Router::new().route("/api/config", get(get_config))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Register the module + route + schema.**
|
||||||
|
- `crates/api/src/lib.rs`: add `mod config;` (alphabetical with other `mod`s) and `.merge(config::routes())` in `build_app` (next to `health::routes()`).
|
||||||
|
- `crates/api/src/openapi.rs`: add `config` to the `use crate::{…}` import; add `config::get_config` to `paths(…)`; add `config::ConfigView` to `components(schemas(…))`.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run → passes.** `cargo test -p api --test config`, then `cargo +nightly fmt`, `cargo clippy --workspace --all-targets`, and full `DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… cargo test -p api -p server` (the AppState field additions compile everywhere).
|
||||||
|
|
||||||
|
- [ ] **Step 9: Regenerate the typed client.**
|
||||||
|
```bash
|
||||||
|
cargo build -p server
|
||||||
|
lsof -ti :8080 | xargs kill 2>/dev/null
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey ./target/debug/server &
|
||||||
|
SERVER_PID=$!
|
||||||
|
sleep 2
|
||||||
|
( cd web && pnpm gen:api )
|
||||||
|
kill "$SERVER_PID"
|
||||||
|
grep -n "ConfigView\|api/config" web/src/api/schema.d.ts
|
||||||
|
```
|
||||||
|
Both must appear; diff additive. `cd web && pnpm typecheck` clean.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit.**
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add crates/server crates/api web/src/api/schema.d.ts
|
||||||
|
git commit -m "feat: DEFAULT_LANGUAGE/DEFAULT_TIMEZONE config + public GET /api/config"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Frontend — config provider + i18n default wiring
|
||||||
|
|
||||||
|
**Files:** Create `web/src/config/config-context.tsx`; Modify `web/src/main.tsx`, `web/src/test/handlers.ts`; Test `web/src/config/config-context.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: MSW handler.** In `web/src/test/handlers.ts`, add to the `handlers` array a default config response:
|
||||||
|
```ts
|
||||||
|
http.get("/api/config", () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
app_name: "Test Museum",
|
||||||
|
default_language: "sv",
|
||||||
|
default_timezone: "Europe/Stockholm",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Failing provider test** — create `web/src/config/config-context.test.tsx`:
|
||||||
|
```tsx
|
||||||
|
import { expect, test, beforeEach } from "vitest";
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import i18n from "../i18n";
|
||||||
|
import { LOCALE_KEY } from "../i18n";
|
||||||
|
import { ConfigProvider, useConfig } from "./config-context";
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
const config = useConfig();
|
||||||
|
return <span data-testid="lang">{config.default_language}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProvider() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<ConfigProvider><Probe /></ConfigProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
void i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exposes config and applies default language when no stored preference", async () => {
|
||||||
|
renderProvider();
|
||||||
|
expect(await screen.findByText("sv")).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(i18n.language).toBe("sv"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stored locale preference wins over the instance default", async () => {
|
||||||
|
localStorage.setItem(LOCALE_KEY, "en");
|
||||||
|
void i18n.changeLanguage("en");
|
||||||
|
renderProvider();
|
||||||
|
await screen.findByText("sv"); // config still loads
|
||||||
|
await waitFor(() => expect(i18n.language).toBe("en")); // but language stays en
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run → fails** (module missing): `cd web && pnpm test src/config/config-context.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement the provider** — create `web/src/config/config-context.tsx`:
|
||||||
|
```tsx
|
||||||
|
import { createContext, useContext, useEffect, type ReactNode } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import i18n, { LOCALE_KEY } from "../i18n";
|
||||||
|
|
||||||
|
type ConfigView = components["schemas"]["ConfigView"];
|
||||||
|
|
||||||
|
const DEFAULTS: ConfigView = {
|
||||||
|
app_name: "Collection Management System",
|
||||||
|
default_language: "sv",
|
||||||
|
default_timezone: "Europe/Stockholm",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConfigContext = createContext<ConfigView>(DEFAULTS);
|
||||||
|
|
||||||
|
export function useConfig(): ConfigView {
|
||||||
|
return useContext(ConfigContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["config"],
|
||||||
|
queryFn: async (): Promise<ConfigView> => {
|
||||||
|
const { data, error } = await api.GET("/api/config");
|
||||||
|
|
||||||
|
if (error || !data) throw new Error("failed to load config");
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default the UI language to the instance default, unless the user has chosen one
|
||||||
|
// for this browser (LangSwitch persists to localStorage[LOCALE_KEY]).
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && !localStorage.getItem(LOCALE_KEY)) {
|
||||||
|
void i18n.changeLanguage(data.default_language);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return <ConfigContext.Provider value={data ?? DEFAULTS}>{children}</ConfigContext.Provider>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run → passes.** `pnpm test src/config/config-context.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Mount the provider.** In `web/src/main.tsx`, wrap `<App />` (inside `QueryClientProvider`, since the provider uses TanStack Query):
|
||||||
|
```tsx
|
||||||
|
import { ConfigProvider } from "./config/config-context";
|
||||||
|
// ...
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ConfigProvider>
|
||||||
|
<App />
|
||||||
|
</ConfigProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify + commit.** `pnpm test && pnpm typecheck && pnpm lint && pnpm build`. All green (existing tests unaffected — MSW now answers `/api/config` so `onUnhandledRequest:"error"` stays happy app-wide).
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add web
|
||||||
|
git commit -m "feat(web): config provider — fetch /api/config, default UI language from instance"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Frontend — single-language content authoring
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/components/label-editor.tsx`, `web/src/objects/field-input.tsx`, `web/src/i18n/{en,sv}.json`, `web/src/components/label-editor.test.tsx`, `web/src/vocab/vocabularies.test.tsx`, `web/src/fields/fields.test.tsx`, `web/src/authorities/authorities.test.tsx`.
|
||||||
|
|
||||||
|
> The content schema, DTOs (`LabelInput`/`LabelView`), DB tables, `LocalizedLabel`, and `FieldType::LocalizedText` are **unchanged**. Only the input components collapse to one language. Reading/display (`labelText`/`pick_label`) already falls back (UI lang → en → first), so single-language data still renders — no change to the read path.
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n key.** Add `labels.label` to BOTH `web/src/i18n/en.json` and `sv.json`:
|
||||||
|
- en `labels`: `"label": "Label"`
|
||||||
|
- sv `labels`: `"label": "Etikett"`
|
||||||
|
(Keep the existing `labels.en`/`labels.sv`/`labels.externalUri` keys — `externalUri` is still used; `labels.en`/`labels.sv` may become unused after this task — if `pnpm lint`/grep shows them unreferenced, remove them from BOTH files to keep parity, else leave.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Collapse `LabelEditor`** — replace `web/src/components/label-editor.tsx` body:
|
||||||
|
```tsx
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { useConfig } from "../config/config-context";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
|
/** Single-language label editor. Authors one label at the instance default language;
|
||||||
|
* emits a one-entry LabelInput[] (empty array when blank). The multilingual data model
|
||||||
|
* is unchanged — this only simplifies authoring. */
|
||||||
|
export function LabelEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: LabelInput[];
|
||||||
|
onChange: (labels: LabelInput[]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { default_language } = useConfig();
|
||||||
|
|
||||||
|
const current =
|
||||||
|
value.find((l) => l.lang === default_language)?.label ?? value[0]?.label ?? "";
|
||||||
|
|
||||||
|
const set = (label: string) =>
|
||||||
|
onChange(label.trim() ? [{ lang: default_language, label }] : []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="label">{t("labels.label")}</Label>
|
||||||
|
<Input id="label" value={current} onChange={(e) => set(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `LabelEditor`'s own test** — `web/src/components/label-editor.test.tsx` currently types into `/label \(en\)/i` + `/label \(sv\)/i` and asserts both langs. Rewrite it for the single input (it must render under a `ConfigProvider` so `useConfig` works — wrap with the test's existing `renderApp`/provider, adding `ConfigProvider`; the MSW `/api/config` handler returns `default_language: "sv"`). New test:
|
||||||
|
```tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { ConfigProvider } from "../config/config-context";
|
||||||
|
import { LabelEditor } from "./label-editor";
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
|
||||||
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
|
function Harness({ onChange }: { onChange: (v: LabelInput[]) => void }) {
|
||||||
|
const [value, setValue] = useState<LabelInput[]>([]);
|
||||||
|
return <LabelEditor value={value} onChange={(v) => { setValue(v); onChange(v); }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("emits a single label at the instance default language", async () => {
|
||||||
|
const seen: LabelInput[][] = [];
|
||||||
|
renderApp(<ConfigProvider><Harness onChange={(v) => seen.push(v)} /></ConfigProvider>);
|
||||||
|
// config (default_language "sv") must load before the editor authors
|
||||||
|
await screen.findByLabelText(/^label$/i);
|
||||||
|
await userEvent.type(screen.getByLabelText(/^label$/i), "Brons");
|
||||||
|
await waitFor(() => {
|
||||||
|
const last = seen[seen.length - 1]!;
|
||||||
|
expect(last).toEqual([{ lang: "sv", label: "Brons" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
NOTE: if `renderApp` doesn't already provide a `QueryClientProvider` that `ConfigProvider` needs, check `web/src/test/render.tsx` — it does wrap `QueryClientProvider` (the vocab/search tests rely on it). The MSW `/api/config` default handler (Task 2) supplies the config.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update the consumer tests.** The forms that use `LabelEditor` have tests typing into `/label \(en\)/i`. They now render a single `/^label$/i` input writing `sv`. Update each:
|
||||||
|
- `web/src/vocab/vocabularies.test.tsx:48` — `getByLabelText(/label \(en\)/i)` → `getByLabelText(/^label$/i)`. These tests render the full app/route tree which must include `ConfigProvider` for `useConfig` — check `renderApp`/the test tree; if the tree doesn't wrap `ConfigProvider`, wrap the rendered subtree in `<ConfigProvider>` (the MSW `/api/config` handler answers). Adjust any assertion expecting an EN/SV pair to the single `sv` label.
|
||||||
|
- `web/src/fields/fields.test.tsx` (3 sites: lines ~38, ~58, ~79) — same `getByLabelText(/^label$/i)` swap + wrap `ConfigProvider` if needed.
|
||||||
|
- `web/src/authorities/authorities.test.tsx:28` — same.
|
||||||
|
Run each file and fix selector/provider issues until green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Collapse the `LocalizedText` field input** — in `web/src/objects/field-input.tsx`, the `case "localized_text":` block renders `${key}.en` + `${key}.sv` inputs. Replace with a single input registering `${key}.${default_language}`. Add `const { default_language } = useConfig();` near the top of the `FieldInput` component (alongside the existing `const lang = …`). New case:
|
||||||
|
```tsx
|
||||||
|
case "localized_text":
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor={definition.key}>{label}</Label>
|
||||||
|
<Input
|
||||||
|
id={definition.key}
|
||||||
|
{...form.register(fieldPath<TValues>(`${definition.key}.${default_language}`), {
|
||||||
|
required: definition.required,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
(Imports: `useConfig` from `../config/config-context`.) The stored value remains a `{ lang: text }` map — now `{ [default_language]: text }`. The `field-input.test.tsx` may reference the EN/SV localized inputs — update it to the single input (register path `${key}.${default_language}`), wrapping with `ConfigProvider` if the test renders the component directly.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify + commit.** `cd web && pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size`. All green; bundle ≤150 KB. en/sv parity holds.
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add web
|
||||||
|
git commit -m "feat(web): single-language content authoring (LabelEditor + localized_text at default lang)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n parity** —
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const k=o=>Object.entries(o).flatMap(([K,v])=>typeof v==='object'?k(v).map(s=>K+'.'+s):[K]);const ka=k(a).sort(),kb=k(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH '+JSON.stringify({onlyEn:ka.filter(x=>!kb.includes(x)),onlySv:kb.filter(x=>!ka.includes(x))}))"
|
||||||
|
```
|
||||||
|
Expected `PARITY OK`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Frontend** — `pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (report bundle gz).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Backend** —
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo +nightly fmt --check
|
||||||
|
```
|
||||||
|
All pass; clippy + fmt clean.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Acceptance spot-checks.**
|
||||||
|
- `cargo run -p server -- --help | grep -E "default-language|default-timezone"` shows both flags.
|
||||||
|
- Content schema untouched: `git diff main..HEAD -- crates/db/migrations crates/domain/src/label.rs` is empty (no schema/domain label changes).
|
||||||
|
- `git grep -in 'biggus\|dickus' -- crates web/src` → none.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
- **Spec coverage:** env knobs + AppState → T1; public `/api/config` → T1; config provider + i18n default → T2; single-language `LabelEditor` + `LocalizedText` → T3; UTC storage unchanged (no timestamp code touched); timezone exposed (no formatter — no consumer, per spec's "forward-ready if none"); parity/bundle → T4. ✓ Per-account UI language + da/no + server-side tz are out of scope (issue #40 / #39). ✓
|
||||||
|
- **Placeholder scan:** none — concrete code; the "wrap ConfigProvider if the test tree doesn't already" notes are real verification steps against named files (the provider dependency is new, so tests that mount label-authoring components need it).
|
||||||
|
- **Type consistency:** `ConfigView { app_name, default_language, default_timezone }` is the single shape across the Rust struct, the `components["schemas"]["ConfigView"]` TS type, the provider `DEFAULTS`, and the MSW handler; `LabelEditor` still emits `LabelInput[]` (one entry); `default_language` threaded from `useConfig()` consistently in both the editor and the field input.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- **Timezone has no frontend consumer yet** (no timestamp is displayed — only `recording_date`, a plain DATE). The value is exposed via `/api/config` + `useConfig` so PDF export (#39) and any future audit/timestamp view can format with it; building a `formatTimestamp` helper now would be unused (YAGNI).
|
||||||
|
- **`AppState` gained two fields** → every `AppState { … }` literal (incl. all api/server test harnesses) must add them or the workspace won't compile; Task 1 Step 3 enumerates this.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,312 @@
|
|||||||
|
# Objects Data-Overview Table + Responsive Shell — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Turn `/objects` into a full-width, sortable, filterable data table (backed by Postgres sort/filter + exposed timestamps), with a collapsible icon sidebar and a responsive detail pane/drawer at a canonical `/objects/:id` URL.
|
||||||
|
|
||||||
|
**Architecture:** Phase 1 adds backend `sort`/`order`/`visibility`/`q` params (injection-safe) + a filtered count + exposes `created_at`/`updated_at`. Phase 2 replaces the narrow `ObjectList` with a full-width `ObjectsTable` whose state lives in the URL. Phase 3 makes the shell sidebar collapsible (lucide icons + Base UI tooltip) and renders detail as a right pane (wide) / Base UI `Drawer` (narrow) via the existing nested `/objects/:id` route.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (axum, sqlx/Postgres, utoipa), React 19 + TS + pnpm, `@base-ui/react` (drawer/collapsible/tooltip — already a dep), `lucide-react` 1.17 (already a dep), react-router 7, TanStack Query, Vitest+RTL+MSW, Storybook 10.
|
||||||
|
|
||||||
|
**Conventions:** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets -- -D warnings`; tests via `cargo nextest run`; pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; component source double-quote+semicolon, stories single-quote+no-semicolon; en/sv parity for new keys; **no codename**; portal queries in tests via `within(document.body)`; `pnpm check:size` budget **165 KB gz**. Test infra: Postgres 5442, Meili 7700; `#[sqlx::test(migrations="../db/migrations")]`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-06-objects-table-and-shell-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
**Backend:** `crates/db/src/catalog.rs` (filtered list+count, sort enum), `crates/api/src/admin_objects.rs` (query params, `AdminObjectView` timestamps), `crates/api/src/openapi.rs` (if new schema types). **Frontend:** `web/src/api/queries.ts` (`useObjectsPage` params), new `web/src/objects/objects-table.tsx` (+ `.stories.tsx`, `.test.tsx`), `web/src/objects/objects-page.tsx` (restructure to table + responsive detail), `web/src/shell/app-shell.tsx` (collapsible sidebar), new `web/src/components/ui/tooltip.tsx`, new `web/src/lib/use-media-query.ts`, `web/src/i18n/{en,sv}.json`. `web/src/objects/object-list.tsx` is removed (replaced by the table).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# PHASE 1 — Backend
|
||||||
|
|
||||||
|
## Task 1: Expose `created_at` / `updated_at` on `AdminObjectView`
|
||||||
|
**Files:** `crates/api/src/admin_objects.rs`; test `crates/api/tests/admin_catalog.rs`.
|
||||||
|
|
||||||
|
The domain `CatalogueObject` already carries `created_at`/`updated_at` (`time::OffsetDateTime`); only the API view omits them. No migration.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing API test** in `admin_catalog.rs`: create an object, `GET /api/admin/objects`, assert the item has non-empty `created_at` and `updated_at` (RFC3339 strings). Run → fails (fields absent).
|
||||||
|
- [ ] **Step 2: Add fields.** In `AdminObjectView` add:
|
||||||
|
```rust
|
||||||
|
/// RFC3339 UTC timestamp.
|
||||||
|
pub created_at: String,
|
||||||
|
/// RFC3339 UTC timestamp.
|
||||||
|
pub updated_at: String,
|
||||||
|
```
|
||||||
|
In `from_object`, map them (the file already has a `format_date` for the `DATE`; for timestamps use RFC3339):
|
||||||
|
```rust
|
||||||
|
created_at: o.created_at.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(),
|
||||||
|
updated_at: o.updated_at.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(),
|
||||||
|
```
|
||||||
|
(Confirm `time` is a dep of the `api` crate; it is used transitively — if not in `Cargo.toml`, add `time.workspace = true`. Verify the `CatalogueObject` field names `created_at`/`updated_at` and their `OffsetDateTime` type in `crates/db/src/catalog.rs:210-211`.)
|
||||||
|
- [ ] **Step 3:** `cargo +nightly fmt`; `cargo clippy -p api`; run the test (compose up):
|
||||||
|
```
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo nextest run -p api -E 'test(admin_catalog)'
|
||||||
|
```
|
||||||
|
- [ ] **Step 4: Commit** `feat(api): expose object created_at/updated_at in AdminObjectView (#44)`.
|
||||||
|
|
||||||
|
## Task 2: Server-side sort / order / visibility / quick-filter for the object list
|
||||||
|
**Files:** `crates/db/src/catalog.rs`, `crates/api/src/admin_objects.rs`; tests in `crates/db/tests/object.rs` (or wherever catalog list is tested) + `crates/api/tests/admin_catalog.rs`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Define a sort enum + filtered db functions** in `crates/db/src/catalog.rs`. Add:
|
||||||
|
```rust
|
||||||
|
/// Whitelisted, injection-safe sort columns for the object list.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum ObjectSort { ObjectNumber, ObjectName, UpdatedAt, CreatedAt, Visibility }
|
||||||
|
|
||||||
|
impl ObjectSort {
|
||||||
|
fn column(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ObjectSort::ObjectNumber => "object_number",
|
||||||
|
ObjectSort::ObjectName => "object_name",
|
||||||
|
ObjectSort::UpdatedAt => "updated_at",
|
||||||
|
ObjectSort::CreatedAt => "created_at",
|
||||||
|
ObjectSort::Visibility => "visibility",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters + ordering for a paged object query. `visibility`/`q` are optional.
|
||||||
|
pub struct ObjectQuery<'a> {
|
||||||
|
pub sort: ObjectSort,
|
||||||
|
pub descending: bool,
|
||||||
|
pub visibility: Option<&'a str>,
|
||||||
|
pub q: Option<&'a str>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Add `list_objects_query` + `count_objects_query` that build SQL from the **enum** (never a raw client string). Both share a WHERE builder. Example:
|
||||||
|
```rust
|
||||||
|
fn where_clause(visibility: Option<&str>, q: Option<&str>) -> (String, Vec<String>) {
|
||||||
|
let mut clauses = Vec::new();
|
||||||
|
let mut binds = Vec::new();
|
||||||
|
if let Some(v) = visibility { binds.push(v.to_owned()); clauses.push(format!("visibility = ${}", binds.len())); }
|
||||||
|
if let Some(term) = q {
|
||||||
|
binds.push(format!("%{term}%"));
|
||||||
|
let p = binds.len();
|
||||||
|
clauses.push(format!("(object_number ILIKE ${p} OR object_name ILIKE ${p})"));
|
||||||
|
}
|
||||||
|
let sql = if clauses.is_empty() { String::new() } else { format!(" WHERE {}", clauses.join(" AND ")) };
|
||||||
|
(sql, binds)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_objects_query(
|
||||||
|
pool: &sqlx::PgPool, query: &ObjectQuery<'_>, limit: i64, offset: i64,
|
||||||
|
) -> Result<Vec<CatalogueObject>, sqlx::Error> {
|
||||||
|
let (where_sql, binds) = where_clause(query.visibility, query.q);
|
||||||
|
let dir = if query.descending { "DESC" } else { "ASC" };
|
||||||
|
// Secondary key keeps ordering stable when the primary sort has ties.
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT {OBJECT_COLUMNS} FROM object{where_sql} ORDER BY {} {dir}, object_number ASC LIMIT ${} OFFSET ${}",
|
||||||
|
query.sort.column(), binds.len() + 1, binds.len() + 2,
|
||||||
|
);
|
||||||
|
let mut q = sqlx::query(&sql);
|
||||||
|
for b in &binds { q = q.bind(b); }
|
||||||
|
let rows = q.bind(limit).bind(offset).fetch_all(pool).await?;
|
||||||
|
rows.into_iter().map(map_object).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn count_objects_query(
|
||||||
|
pool: &sqlx::PgPool, visibility: Option<&str>, q: Option<&str>,
|
||||||
|
) -> Result<i64, sqlx::Error> {
|
||||||
|
let (where_sql, binds) = where_clause(visibility, q);
|
||||||
|
let sql = format!("SELECT count(*) AS n FROM object{where_sql}");
|
||||||
|
let mut query = sqlx::query(&sql);
|
||||||
|
for b in &binds { query = query.bind(b); }
|
||||||
|
query.fetch_one(pool).await?.try_get("n")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Keep the existing `list_objects_paged`/`count_objects` if other callers use them (grep; if only the handler calls them, you may replace — verify). The `ObjectColumns`/`map_object` already include the timestamp columns.
|
||||||
|
- [ ] **Step 2: db tests** in the catalog test file: seed objects with distinct names/visibilities; assert `list_objects_query` orders by `object_name DESC`, filters by `visibility="draft"`, and `q` ILIKE matches number/name; `count_objects_query` returns the filtered count.
|
||||||
|
- [ ] **Step 3: Handler query params.** In `admin_objects.rs`, add a deserialize struct (don't overload the shared `Pagination`):
|
||||||
|
```rust
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(crate) struct ObjectListParams {
|
||||||
|
pub limit: Option<i64>, pub offset: Option<i64>,
|
||||||
|
pub sort: Option<String>, pub order: Option<String>,
|
||||||
|
pub visibility: Option<String>, pub q: Option<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Parse `sort` → `ObjectSort` (unknown → default `ObjectNumber`), `order` → `descending = order == "desc"`, clamp limit (1..=200, default 50) / offset (>=0) like `Pagination`. Validate `visibility` against `domain::Visibility` (unknown → 422 or ignore — pick ignore-with-default for resilience to hand-edited URLs). Build `ObjectQuery`, call `list_objects_query` + `count_objects_query`. Update the `#[utoipa::path]` `params(...)` to document `sort`/`order`/`visibility`/`q`.
|
||||||
|
- [ ] **Step 4: API test** — `GET /api/admin/objects?sort=object_name&order=desc&visibility=draft&q=foo` returns filtered+sorted items and a matching `total`; no params → unchanged default (object_number asc).
|
||||||
|
- [ ] **Step 5:** fmt + clippy + `cargo nextest run -p api -p db`. **Commit** `feat: object list sort/filter/quick-search (server-side, injection-safe) (#44)`.
|
||||||
|
|
||||||
|
## Task 3: Regenerate web API types
|
||||||
|
- [ ] Start the built server on an alt port (8080 may be taken): `BIND_ADDR=127.0.0.1:8090 DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… ./target/debug/server`, then `cd web && pnpm exec openapi-typescript http://localhost:8090/api-docs/openapi.json -o src/api/schema.d.ts`. Verify `created_at`/`updated_at` appear on `AdminObjectView`; `pnpm typecheck`. Stop the server. **Commit** `chore(web): regenerate API types (object list params + timestamps)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# PHASE 2 — The table
|
||||||
|
|
||||||
|
## Task 4: `useObjectsPage` gains sort/filter params
|
||||||
|
**Files:** `web/src/api/queries.ts`.
|
||||||
|
- [ ] Replace the `(limit, offset)` signature with a params object and `keepPreviousData`:
|
||||||
|
```ts
|
||||||
|
import { keepPreviousData } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export type ObjectListParams = {
|
||||||
|
limit: number; offset: number;
|
||||||
|
sort?: string; order?: "asc" | "desc";
|
||||||
|
visibility?: string; q?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useObjectsPage(params: ObjectListParams) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["objects", params],
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await api.GET("/api/admin/objects", {
|
||||||
|
params: { query: {
|
||||||
|
limit: params.limit, offset: params.offset,
|
||||||
|
sort: params.sort, order: params.order,
|
||||||
|
visibility: params.visibility, q: params.q,
|
||||||
|
} },
|
||||||
|
});
|
||||||
|
if (error || !data) throw new Error("failed to load objects");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(openapi-fetch drops `undefined` query params, so omit-by-undefined is fine.) Update the existing call site in `object-list.tsx` — but that file is being replaced in Task 5; if Task 5 lands in the same branch, just ensure typecheck passes after Task 5. **Commit with Task 5** (or standalone if you prefer). Keep `useObject` unchanged.
|
||||||
|
|
||||||
|
## Task 5: `ObjectsTable` — full-width table, URL-synced state, pagination, sort headers
|
||||||
|
**Files:** create `web/src/objects/objects-table.tsx`, `objects-table.stories.tsx`, `objects-table.test.tsx`; delete `web/src/objects/object-list.tsx`.
|
||||||
|
|
||||||
|
Behavior: reads all state from the URL (`useSearchParams`) — `sort`, `order`, `q`, `visibility`, `offset`, `limit` (default sort `object_number`/`asc`, limit 50, offset 0). Renders a real `<table>`; reuses `VisibilityBadge`; columns № / Name / Visibility / Location / # / Updated; sortable headers toggle sort+dir (with `aria-sort`); a row is a `<tr>` whose click navigates to `/objects/:id` **preserving the current search string** (so back restores state); pagination footer with prev/next + page-size `<select>` (or the future `ui/select`); a debounced quick-filter `Input` (`q`) and visibility chips live in a toolbar (Task 6 may own the toolbar — implement them here together to keep the table coherent).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Component.** Concrete core (fill routine markup/classes to match the app; use token classes per #49 where easy, else existing patterns):
|
||||||
|
```tsx
|
||||||
|
import { useSearchParams, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { useObjectsPage } from "../api/queries";
|
||||||
|
import { useDebouncedValue } from "../lib/use-debounced-value";
|
||||||
|
import { VisibilityBadge } from "./visibility-badge";
|
||||||
|
// + ui/button, ui/input, ui/skeleton, lucide chevrons
|
||||||
|
|
||||||
|
const SORTABLE = ["object_number", "object_name", "updated_at"] as const;
|
||||||
|
const PAGE_SIZES = [25, 50, 100, 200];
|
||||||
|
const VIS = ["all", "draft", "internal", "public"] as const;
|
||||||
|
|
||||||
|
export function ObjectsTable() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id: selectedId } = useParams(); // highlight the open row
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
|
||||||
|
const sort = params.get("sort") ?? "object_number";
|
||||||
|
const order = (params.get("order") === "desc" ? "desc" : "asc") as "asc" | "desc";
|
||||||
|
const visibility = params.get("visibility") ?? "all";
|
||||||
|
const limit = Number(params.get("limit")) || 50;
|
||||||
|
const offset = Number(params.get("offset")) || 0;
|
||||||
|
const qParam = params.get("q") ?? "";
|
||||||
|
const [qText, setQText] = useState(qParam);
|
||||||
|
const q = useDebouncedValue(qText, 300);
|
||||||
|
|
||||||
|
// sync debounced q → URL (reset offset)
|
||||||
|
useEffect(() => {
|
||||||
|
setParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
const term = q.trim();
|
||||||
|
if (term) next.set("q", term); else next.delete("q");
|
||||||
|
next.delete("offset");
|
||||||
|
return next;
|
||||||
|
}, { replace: true });
|
||||||
|
}, [q, setParams]);
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useObjectsPage({
|
||||||
|
limit, offset, sort, order,
|
||||||
|
visibility: visibility === "all" ? undefined : visibility,
|
||||||
|
q: q.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setParam = (mutate: (n: URLSearchParams) => void) =>
|
||||||
|
setParams((prev) => { const n = new URLSearchParams(prev); mutate(n); return n; }, { replace: true });
|
||||||
|
|
||||||
|
const toggleSort = (col: string) =>
|
||||||
|
setParam((n) => {
|
||||||
|
const curOrder = n.get("order") === "desc" ? "desc" : "asc";
|
||||||
|
const curSort = n.get("sort") ?? "object_number";
|
||||||
|
const nextOrder = curSort === col && curOrder === "asc" ? "desc" : "asc";
|
||||||
|
n.set("sort", col); n.set("order", nextOrder); n.delete("offset");
|
||||||
|
});
|
||||||
|
|
||||||
|
// header cell: aria-sort = col===sort ? (order==='asc'?'ascending':'descending') : 'none'
|
||||||
|
// row: <tr onClick={() => navigate(`/objects/${o.id}?${params}`)} aria-selected={o.id===selectedId} ...>
|
||||||
|
// pagination: prev disabled offset===0; next disabled offset+limit>=total; page-size select sets limit + deletes offset
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Render loading via `Skeleton` rows; error → `objects.loadError`; empty → `objects.empty`. Visibility chips mirror the search-panel `<button aria-pressed>` pattern (set `visibility` param, delete `offset`). The "Updated" cell: format `o.updated_at` with `Intl.DateTimeFormat(i18n.language, { dateStyle:'medium', timeZone: useConfig().default_timezone })` (or a relative-time helper) — keep it a small local helper. **No `any`** (cast page items as `components["schemas"]["AdminObjectView"]`).
|
||||||
|
- [ ] **Step 2: i18n** — add `objects.columns.{number,name,visibility,location,count,updated}`, `objects.filter` (quick-filter placeholder), `objects.pageSize`, `objects.all` (or reuse `search.all`) to **both** `en.json` and `sv.json`.
|
||||||
|
- [ ] **Step 3: Stories** `objects-table.stories.tsx` — render inside a `MemoryRouter` (the preview provides providers; add a router if needed) with MSW returning a small page: `Default` (rows render), `Sorted` (assert `aria-sort` on the active header), `Empty`. Mirror the visibility-badge story format.
|
||||||
|
- [ ] **Step 4: Unit test** `objects-table.test.tsx` (RTL + MSW + MemoryRouter): rows render the columns; clicking a sortable header updates the URL `sort`/`order` and sets `aria-sort`; typing in the filter (debounced) sets `q`; a visibility chip sets `visibility`; pagination next/prev change `offset`; page-size sets `limit`. Use the search-panel test as a reference for MSW + router wiring.
|
||||||
|
- [ ] **Step 5:** `pnpm typecheck && pnpm lint && pnpm test -- objects-table`. **Commit** `feat(web): full-width sortable/filterable objects table with URL state (#44)`.
|
||||||
|
|
||||||
|
## Task 6: Wire the table into the page (table full-width; detail via Outlet placeholder)
|
||||||
|
**Files:** `web/src/objects/objects-page.tsx` (interim — full restructure in Phase 3).
|
||||||
|
- [ ] Make `ObjectsPage` render `ObjectsTable` full-width for now, keeping the nested `<Outlet/>` available but not as a fixed 20rem column (Phase 3 makes it a pane/drawer). Interim acceptable state: table fills the area; if a `:id` child route is active, render the detail below/over as a simple panel (Phase 3 makes it responsive). Remove the `index → SelectPrompt` route's visual prominence (the table is the landing view). **Verify** `pnpm test && pnpm build`. **Commit** `feat(web): objects table as the /objects landing view (#44)`.
|
||||||
|
|
||||||
|
> Note: Tasks 5–6 can be one commit if cleaner. The key is the table renders at `/objects` and row-click deep-links to `/objects/:id` with preserved query state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# PHASE 3 — Shell & responsive detail
|
||||||
|
|
||||||
|
## Task 7: `useMediaQuery` hook + `ui/tooltip.tsx` wrapper
|
||||||
|
**Files:** create `web/src/lib/use-media-query.ts`, `web/src/components/ui/tooltip.tsx`.
|
||||||
|
- [ ] **`use-media-query.ts`** (tiny, SSR-safe, mirrors `use-debounced-value`):
|
||||||
|
```ts
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState(() =>
|
||||||
|
typeof window !== "undefined" ? window.matchMedia(query).matches : false);
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia(query);
|
||||||
|
const on = () => setMatches(mql.matches);
|
||||||
|
on(); mql.addEventListener("change", on);
|
||||||
|
return () => mql.removeEventListener("change", on);
|
||||||
|
}, [query]);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] **`ui/tooltip.tsx`** — wrap `@base-ui/react/tooltip` parts (Provider/Root/Trigger/Portal/Positioner/Popup) in the established `ui/*` style (mirror `ui/alert-dialog.tsx`: `data-slot`, `cn`, `render=` where a trigger delegates). Export a simple `<Tooltip content=…>{trigger}</Tooltip>` convenience plus the raw parts. **RUN a quick story/test** to confirm the Base UI composition (first tooltip in the repo — verify the part tree by running, like the combobox was). No `any`.
|
||||||
|
- [ ] Typecheck/lint. **Commit** `feat(web): useMediaQuery hook + Base UI tooltip wrapper (#44)`.
|
||||||
|
|
||||||
|
## Task 8: Collapsible icon sidebar
|
||||||
|
**Files:** `web/src/shell/app-shell.tsx` (+ optional `sidebar.stories.tsx`).
|
||||||
|
- [ ] Add lucide icons to each nav item (e.g. `Boxes`/`BookMarked`/`Users`/`Search`/`Tags` — pick sensible icons). Add a collapse toggle button; persist `collapsed` to `localStorage` (`sidebar-collapsed`); auto-collapse when `useMediaQuery("(max-width: 768px)")`. Expanded: icon + label (`w-44`). Collapsed: icon only (`~w-14`) with the label via the `ui/tooltip` (and `aria-label`/`title`). Preserve `NavLink` active styling; add `focus-visible` rings.
|
||||||
|
- [ ] **Story** `app-shell` sidebar or a extracted `Sidebar` component: `Expanded` / `Collapsed` (assert labels hidden + tooltips/`aria-label` present). If extracting a `Sidebar` component from `app-shell` makes it testable/storyable, do so (keep `app-shell` thin).
|
||||||
|
- [ ] Typecheck/lint/test. **Commit** `feat(web): collapsible icon sidebar (persisted, auto-collapse on narrow) (#44, #58)`.
|
||||||
|
|
||||||
|
## Task 9: Responsive detail — right pane (wide) / Drawer (narrow) at canonical `/objects/:id`
|
||||||
|
**Files:** `web/src/objects/objects-page.tsx`; possibly a small `object-detail-panel.tsx`.
|
||||||
|
- [ ] Restructure `ObjectsPage`: always render `ObjectsTable`; detect an active detail child with `useMatch("/objects/:id")` / `useMatch("/objects/:id/edit")`. When matched:
|
||||||
|
- **Wide** (`useMediaQuery("(min-width: 1024px)")`): render a right-hand pane (e.g. `grid-cols-[1fr_28rem]` when open, else `1fr`) containing `<Outlet/>`, with a close control (`navigate("/objects?"+params)`).
|
||||||
|
- **Narrow:** render `<Outlet/>` inside a Base UI `Drawer` (`swipeDirection="right"`, edge = right) over the table; closing the drawer navigates back to `/objects` (preserve query). **RUN to confirm** the Drawer part tree (Root/Portal/Backdrop/Popup/Close) — first Drawer in the repo; mirror the alert-dialog wrapper conventions.
|
||||||
|
- Remove the `index → SelectPrompt` route (the table is the landing view); `SelectPrompt` can be deleted if now unused (grep — it may also be used elsewhere; only remove if exclusively the objects index).
|
||||||
|
- `:id/edit` continues to render through the same `<Outlet/>` (pane/drawer), preserving today's "edit in the right area" behavior.
|
||||||
|
- [ ] **Test:** with a mocked `matchMedia`, `/objects/:id` renders detail in a pane (wide) and in a portaled Drawer (narrow, query via `within(document.body)`); closing returns to `/objects` with the table's query string intact; deep-linking `/objects/:id` directly renders table + open detail.
|
||||||
|
- [ ] Typecheck/lint/test/build. **Commit** `feat(web): responsive object detail (pane/drawer) at canonical /objects/:id (#44, #58)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# PHASE 4 — Verification
|
||||||
|
|
||||||
|
## Task 10: Final verification
|
||||||
|
- [ ] Backend: `cargo +nightly fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings`; `DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… cargo nextest run --workspace` (single clean run — don't run two concurrently; sqlx temp-DB contention produces fake failures).
|
||||||
|
- [ ] Web: `cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (index ≤ **165 KB gz** — lucide/tooltip/drawer land in the always-loaded shell; tree-shaken — verify and report the number).
|
||||||
|
- [ ] `pnpm test -- i18n` (en/sv parity for the new `objects.columns.*` etc.); `git grep -in 'biggus\|dickus' -- crates web/src || echo CLEAN`; `git status --short` clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
**Spec coverage:** sort/filter/q + filtered total + timestamps (T1–T3); full-width table with columns/sort/filter/pagination/URL-state (T4–T6); collapsible icon sidebar (T8); responsive pane/drawer + canonical `/objects/:id` (T7,T9); stories (T5,T7,T8); bundle/parity/codename (T10). ✓ Out of scope (Meili unification, detail-content #45, multi-select) not included. ✓
|
||||||
|
**Placeholder scan:** load-bearing logic (SQL builder, sort enum, URL-state wiring, sort toggle, responsive routing, media-query/tooltip) is concrete; routine table markup/classes are described to match existing patterns; the two novel Base UI primitives (Tooltip, Drawer) carry explicit "verify the part tree by running" steps (same approach that worked for the combobox), with canonical trees from the spec. No "TBD"/"add error handling".
|
||||||
|
**Type consistency:** `ObjectSort` enum + `ObjectQuery` (db) ↔ `ObjectListParams` (api) ↔ `useObjectsPage(ObjectListParams)` (web) align on sort/order/visibility/q; `AdminObjectView` gains `created_at`/`updated_at` (T1) consumed by the table's Updated column (T5). URL param names (`sort`/`order`/`visibility`/`q`/`limit`/`offset`) consistent across table read/write and the hook.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- `lucide-react` + Base UI tooltip/drawer/collapsible are already deps → no `pnpm-lock` churn.
|
||||||
|
- No DB migration (timestamps already exist).
|
||||||
|
- Watch the bundle: icons/tooltip/drawer are in the always-loaded shell, not a lazy chunk — if `check:size` exceeds 165, lazy-import the Drawer (only used at narrow widths) or trim.
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
# Searchable Term/Authority Picker Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the native `<select>` for term/authority object fields with a searchable combobox (type-to-filter by active-locale label, client-side), built on Base UI's `combobox` primitive — keeping the `value = id` contract.
|
||||||
|
|
||||||
|
**Architecture:** A styled wrapper `ui/combobox.tsx` over `@base-ui/react/combobox` (mirroring the existing `ui/alert-dialog.tsx` Base UI wrapper), consumed by a focused `OptionsCombobox` component with the **same prop contract** as today's `OptionsSelect`, dropped into `TermField`/`AuthorityField`. No backend change; `useTerms`/`useAuthorities` unchanged.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19 + TypeScript + pnpm, `@base-ui/react` v1.5.0 (already a dependency), Tailwind v4, react-hook-form, Vitest + RTL + MSW, Storybook 10.
|
||||||
|
|
||||||
|
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; component source = double quotes + semicolons, stories = single quotes + no semicolons; en/sv parity for any new keys; no codename ("biggus"/"dickus"); per-test portal queries use `within(document.body)`. Tests: `cd web && pnpm test` (vitest, jsdom + storybook projects), `pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm check:size`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-06-searchable-term-authority-picker-design.md`
|
||||||
|
|
||||||
|
**Base UI Combobox — canonical single-select composition** (import `@base-ui/react/combobox`; `value` is the **item object** or `null`; `onValueChange(item)` gives the item; filtering is built-in against `itemToStringLabel`):
|
||||||
|
```tsx
|
||||||
|
<Combobox.Root items={items} value={value} onValueChange={setValue}
|
||||||
|
itemToStringLabel={(it) => it.label} isItemEqualToValue={(a, b) => a?.id === b?.id}>
|
||||||
|
<Combobox.InputGroup>
|
||||||
|
<Combobox.Input placeholder="…" id={id} />
|
||||||
|
<Combobox.Clear aria-label="Clear" />
|
||||||
|
<Combobox.Trigger aria-label="Open" />
|
||||||
|
</Combobox.InputGroup>
|
||||||
|
<Combobox.Portal>
|
||||||
|
<Combobox.Positioner sideOffset={4}>
|
||||||
|
<Combobox.Popup>
|
||||||
|
<Combobox.Empty>No matches.</Combobox.Empty>
|
||||||
|
<Combobox.List>
|
||||||
|
{(item) => (
|
||||||
|
<Combobox.Item key={item.id} value={item}>
|
||||||
|
<Combobox.ItemIndicator>✓</Combobox.ItemIndicator>
|
||||||
|
{item.label}
|
||||||
|
</Combobox.Item>
|
||||||
|
)}
|
||||||
|
</Combobox.List>
|
||||||
|
</Combobox.Popup>
|
||||||
|
</Combobox.Positioner>
|
||||||
|
</Combobox.Portal>
|
||||||
|
</Combobox.Root>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `web/src/components/ui/combobox.tsx` (new) — styled passthrough wrappers over the Base UI `Combobox.*` parts (mirror `ui/alert-dialog.tsx`'s conventions: `data-slot`, `cn()`, re-export composed parts).
|
||||||
|
- `web/src/objects/options-combobox.tsx` (new) — `OptionsCombobox`, the drop-in picker (same prop contract as `OptionsSelect`), composing the wrapper parts for `{ id, labels }` options. Extracted to its own file so it is focused, unit-testable, and storyable.
|
||||||
|
- `web/src/objects/options-combobox.stories.tsx` (new) — Storybook stories.
|
||||||
|
- `web/src/objects/options-combobox.test.tsx` (new) — unit test (open/filter/select/clear).
|
||||||
|
- `web/src/objects/field-input.tsx` (modify) — `TermField`/`AuthorityField` render `OptionsCombobox`; delete `OptionsSelect`.
|
||||||
|
- `web/src/objects/field-input.test.tsx` (modify) — update the term/authority cases for the combobox.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Combobox component (`ui/combobox.tsx` + `OptionsCombobox` + story + unit test)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/components/ui/combobox.tsx`, `web/src/objects/options-combobox.tsx`, `web/src/objects/options-combobox.stories.tsx`, `web/src/objects/options-combobox.test.tsx`
|
||||||
|
|
||||||
|
**Before coding:** READ `web/src/components/ui/alert-dialog.tsx` (the Base UI wrapper conventions: `import { X as XPrimitive } from "@base-ui/react/x"`, `data-slot` attributes, `cn()` class merge, `render={…}` trigger style). The exact Base UI `Combobox.*` part prop types are in `node_modules/@base-ui/react/combobox/` — consult them if a passthrough type is unclear.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the styled wrapper** `web/src/components/ui/combobox.tsx`. Wrap the Base UI parts the picker needs, with Tailwind classes consistent with the existing inputs/menus (the native `<select>` used `w-full rounded border px-2 py-1 text-sm`; the popup should look like a menu surface). Concrete starting implementation (adjust class details to match the app's look; keep the structure):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as React from "react";
|
||||||
|
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function ComboboxRoot<Value>(props: ComboboxPrimitive.Root.Props<Value>) {
|
||||||
|
return <ComboboxPrimitive.Root data-slot="combobox" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxInputGroup({ className, ...props }: ComboboxPrimitive.InputGroup.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.InputGroup
|
||||||
|
data-slot="combobox-input-group"
|
||||||
|
className={cn("relative flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Input
|
||||||
|
data-slot="combobox-input"
|
||||||
|
className={cn("w-full rounded border px-2 py-1 pr-12 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Clear
|
||||||
|
data-slot="combobox-clear"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-6 text-neutral-400 hover:text-neutral-700",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxTrigger({ className, ...props }: ComboboxPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Trigger
|
||||||
|
data-slot="combobox-trigger"
|
||||||
|
className={cn("absolute right-1 text-neutral-500", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxPopup({ className, ...props }: ComboboxPrimitive.Popup.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Portal>
|
||||||
|
<ComboboxPrimitive.Positioner sideOffset={4} className="z-50">
|
||||||
|
<ComboboxPrimitive.Popup
|
||||||
|
data-slot="combobox-popup"
|
||||||
|
className={cn(
|
||||||
|
"max-h-64 w-[var(--anchor-width)] overflow-auto rounded border bg-white p-1 text-sm shadow-md",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ComboboxPrimitive.Positioner>
|
||||||
|
</ComboboxPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxList(props: ComboboxPrimitive.List.Props) {
|
||||||
|
return <ComboboxPrimitive.List data-slot="combobox-list" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxItem({ className, ...props }: ComboboxPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Item
|
||||||
|
data-slot="combobox-item"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-2 rounded px-2 py-1 data-[highlighted]:bg-indigo-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Empty
|
||||||
|
data-slot="combobox-empty"
|
||||||
|
className={cn("px-2 py-1 text-neutral-500", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ComboboxRoot,
|
||||||
|
ComboboxInputGroup,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxClear,
|
||||||
|
ComboboxTrigger,
|
||||||
|
ComboboxPopup,
|
||||||
|
ComboboxList,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxEmpty,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
If a part's `.Props` type path differs (verify against the d.ts), adjust the type annotation — do **not** fall back to `any`. (`--anchor-width` is Base UI's positioner CSS var for matching the input width; if it isn't exposed under that name, use `min-w-[12rem]` instead — confirm when you run the story.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write `OptionsCombobox`** `web/src/objects/options-combobox.tsx` — the drop-in with the exact contract of the old `OptionsSelect`. It converts between the rhf `value` (id string) and the Base UI item object, and filters/displays by active-locale label.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import {
|
||||||
|
ComboboxRoot,
|
||||||
|
ComboboxInputGroup,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxClear,
|
||||||
|
ComboboxTrigger,
|
||||||
|
ComboboxPopup,
|
||||||
|
ComboboxList,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxEmpty,
|
||||||
|
} from "@/components/ui/combobox";
|
||||||
|
|
||||||
|
type LabelView = components["schemas"]["LabelView"];
|
||||||
|
|
||||||
|
export type Option = { id: string; labels: LabelView[] };
|
||||||
|
|
||||||
|
function labelIn(labels: LabelView[], lang: string): string {
|
||||||
|
return (
|
||||||
|
labels.find((l) => l.lang === lang)?.label ??
|
||||||
|
labels.find((l) => l.lang === "en")?.label ??
|
||||||
|
labels[0]?.label ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OptionsCombobox({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
lang,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
options: Option[];
|
||||||
|
lang: string;
|
||||||
|
placeholder: string;
|
||||||
|
}) {
|
||||||
|
const selected = options.find((o) => o.id === value) ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComboboxRoot<Option | null>
|
||||||
|
items={options}
|
||||||
|
value={selected}
|
||||||
|
onValueChange={(option) => onChange(option?.id ?? "")}
|
||||||
|
itemToStringLabel={(option) => (option ? labelIn(option.labels, lang) : "")}
|
||||||
|
isItemEqualToValue={(a, b) => a?.id === b?.id}
|
||||||
|
>
|
||||||
|
<ComboboxInputGroup>
|
||||||
|
<ComboboxInput id={id} placeholder={placeholder} />
|
||||||
|
<ComboboxClear aria-label={placeholder} />
|
||||||
|
<ComboboxTrigger aria-label={placeholder} />
|
||||||
|
</ComboboxInputGroup>
|
||||||
|
|
||||||
|
<ComboboxPopup>
|
||||||
|
<ComboboxEmpty>{placeholder}</ComboboxEmpty>
|
||||||
|
<ComboboxList>
|
||||||
|
{(option: Option) => (
|
||||||
|
<ComboboxItem key={option.id} value={option}>
|
||||||
|
{labelIn(option.labels, lang)}
|
||||||
|
</ComboboxItem>
|
||||||
|
)}
|
||||||
|
</ComboboxList>
|
||||||
|
</ComboboxPopup>
|
||||||
|
</ComboboxRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Notes:
|
||||||
|
- `labelIn` is duplicated here from `field-input.tsx`. In Task 2 you will **export `labelIn` from a shared spot** (see Task 2 Step 3) and import it in both — for now define it locally so this file compiles standalone; Task 2 dedupes.
|
||||||
|
- Confirm the generic on `ComboboxRoot<Option | null>` matches the wrapper's `Root.Props<Value>` signature; if Base UI's `value`/`onValueChange`/`itemToStringLabel`/`isItemEqualToValue` prop names differ from the canonical example, adjust to the real names from the d.ts (you already have: `items`, `value`, `onValueChange`, `itemToStringLabel`, `isItemEqualToValue`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the unit test** `web/src/objects/options-combobox.test.tsx`. Render with two options, exercise open → filter → select → clear. The popup is portaled — query via `within(document.body)`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
|
import { OptionsCombobox, type Option } from "./options-combobox";
|
||||||
|
|
||||||
|
const options: Option[] = [
|
||||||
|
{ id: "t1", labels: [{ lang: "en", label: "Wood" }] },
|
||||||
|
{ id: "t2", labels: [{ lang: "en", label: "Bronze" }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function setup(value = "") {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<OptionsCombobox
|
||||||
|
id="material"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={options}
|
||||||
|
lang="en"
|
||||||
|
placeholder="Select…"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { onChange };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OptionsCombobox", () => {
|
||||||
|
it("filters by label and selects the option id", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onChange } = setup();
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("Select…");
|
||||||
|
await user.click(input);
|
||||||
|
await user.type(input, "bro");
|
||||||
|
|
||||||
|
const body = within(document.body);
|
||||||
|
// Only the matching option is listed.
|
||||||
|
expect(body.queryByText("Wood")).toBeNull();
|
||||||
|
await user.click(await body.findByText("Bronze"));
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith("t2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the selected option's label", () => {
|
||||||
|
setup("t1");
|
||||||
|
expect(screen.getByDisplayValue("Wood")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
(If `getByDisplayValue` doesn't match how Base UI renders the selected label in the input, assert via the input's `value` attribute instead — confirm by running. Run the test before finalizing the assertions.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the unit test.**
|
||||||
|
```
|
||||||
|
cd web && pnpm test -- options-combobox
|
||||||
|
```
|
||||||
|
Expected: PASS. If the Base UI composition needs adjustment (portal target, prop names, selected-label display), fix the wrapper/component and re-run until green. **Do not** weaken assertions to pass — the test must genuinely prove filter + select-by-id + selected-label.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Write the Storybook story** `web/src/objects/options-combobox.stories.tsx` (mirror `web/src/objects/visibility-badge.stories.tsx` format: `@storybook/react-vite`, `storybook/test`, `tags: ['ai-generated']`, single quotes, no semicolons). Stories: `Default` (placeholder visible), `Selected` (value set → label shown), and `FiltersOnType` (type → only the match shows; portal → `within(document.body)`).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||||
|
import { expect, userEvent, fn, within } from 'storybook/test'
|
||||||
|
|
||||||
|
import { OptionsCombobox, type Option } from './options-combobox'
|
||||||
|
|
||||||
|
const options: Option[] = [
|
||||||
|
{ id: 't1', labels: [{ lang: 'en', label: 'Wood' }] },
|
||||||
|
{ id: 't2', labels: [{ lang: 'en', label: 'Bronze' }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
component: OptionsCombobox,
|
||||||
|
tags: ['ai-generated'],
|
||||||
|
args: { id: 'material', value: '', onChange: fn(), options, lang: 'en', placeholder: 'Select…' },
|
||||||
|
} satisfies Meta<typeof OptionsCombobox>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
play: async ({ canvas }) => {
|
||||||
|
await expect(canvas.getByPlaceholderText('Select…')).toBeVisible()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Selected: Story = {
|
||||||
|
args: { value: 't1' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FiltersOnType: Story = {
|
||||||
|
play: async ({ canvas, args }) => {
|
||||||
|
const input = canvas.getByPlaceholderText('Select…')
|
||||||
|
await userEvent.click(input)
|
||||||
|
await userEvent.type(input, 'bro')
|
||||||
|
const body = within(document.body)
|
||||||
|
await userEvent.click(await body.findByText('Bronze'))
|
||||||
|
await expect(args.onChange).toHaveBeenCalledWith('t2')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the stories + typecheck + lint.**
|
||||||
|
```
|
||||||
|
cd web && pnpm test -- options-combobox && pnpm typecheck && pnpm lint
|
||||||
|
```
|
||||||
|
Expected: PASS, no `any`/disable.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit.**
|
||||||
|
```bash
|
||||||
|
git add web/src/components/ui/combobox.tsx web/src/objects/options-combobox.tsx web/src/objects/options-combobox.stories.tsx web/src/objects/options-combobox.test.tsx
|
||||||
|
git commit -m "feat(web): searchable combobox (Base UI) for term/authority options (#27)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Wire into the object form
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/objects/field-input.tsx`
|
||||||
|
- Modify: `web/src/objects/field-input.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Use `OptionsCombobox` in `TermField`/`AuthorityField`** (`field-input.tsx`). Replace the `<OptionsSelect … />` rendered inside each `Controller` with `<OptionsCombobox … />` (the props are identical: `id`, `value`, `onChange`, `options`, `lang`, `placeholder`). Add the import:
|
||||||
|
```tsx
|
||||||
|
import { OptionsCombobox } from "./options-combobox";
|
||||||
|
```
|
||||||
|
Then **delete the now-unused `OptionsSelect` function** and the stale comment above it ("A native `<select>` keeps the bundle lean…").
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify no other references to `OptionsSelect`.**
|
||||||
|
```
|
||||||
|
cd web && grep -rn "OptionsSelect" src
|
||||||
|
```
|
||||||
|
Expected: no matches (it's removed).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Dedupe `labelIn`.** `field-input.tsx` and `options-combobox.tsx` both define `labelIn`. Export it from `options-combobox.tsx` (add `export` to its `labelIn`) and import it in `field-input.tsx`, removing `field-input.tsx`'s local copy:
|
||||||
|
```tsx
|
||||||
|
// field-input.tsx
|
||||||
|
import { OptionsCombobox, labelIn } from "./options-combobox";
|
||||||
|
```
|
||||||
|
Confirm `field-input.tsx` still uses `labelIn` for its `definition.labels` rendering (it does, in `FieldInput`). (If you prefer not to couple `field-input` to `options-combobox` for a helper, instead move `labelIn` to `web/src/lib/labels.ts` if that module exists — check `web/src/lib/` — and import from there in both. Pick one; do not leave two copies.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `field-input.test.tsx`** for the combobox. Find the existing term and/or authority test cases (they currently interact with a native `<select>` — e.g. `selectOptions` or asserting `<option>`s) and rewrite them to drive the combobox: render the object form (or the field), open the combobox, type to filter, click the option, and assert the submitted/registered value is the term/authority **id**. Use `within(document.body)` for the portaled popup. Leave the text/integer/date/boolean/localized_text cases unchanged.
|
||||||
|
- Read the current `field-input.test.tsx` to see exactly how the term/authority cases are set up (MSW handlers for `useTerms`/`useAuthorities`, the form wrapper) and adapt those specific cases; do not rewrite the whole file.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the field-input tests + full web suite.**
|
||||||
|
```
|
||||||
|
cd web && pnpm test -- field-input && pnpm test && pnpm typecheck && pnpm lint
|
||||||
|
```
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit.**
|
||||||
|
```bash
|
||||||
|
git add web/src/objects/field-input.tsx web/src/objects/field-input.test.tsx
|
||||||
|
git commit -m "feat(web): use searchable combobox for term/authority fields on the object form (#27)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Final verification
|
||||||
|
|
||||||
|
**Files:** none (verification only).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full web gate.**
|
||||||
|
```
|
||||||
|
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
|
||||||
|
```
|
||||||
|
Expected: all green; `check:size` reports the index chunk ≤ 150 KB gz (the combobox lands in the lazy object-form chunk — confirm the index didn't materially grow).
|
||||||
|
|
||||||
|
- [ ] **Step 2: en/sv parity + codename + no leftover select.**
|
||||||
|
```
|
||||||
|
cd web && pnpm test -- i18n
|
||||||
|
git grep -in 'biggus\|dickus' -- web/src || echo "CODENAME CLEAN"
|
||||||
|
grep -rn "OptionsSelect" web/src || echo "OptionsSelect removed"
|
||||||
|
```
|
||||||
|
Expected: parity passes; codename clean; `OptionsSelect` gone.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual smoke (recommended).** `docker compose up -d`, run the server + `pnpm dev`, open the object create form for an object type with a `term`/`authority` field (seed a vocabulary with a few terms first via `/vocabularies`), and confirm: typing filters; selecting stores the id (the object saves and the value round-trips on edit); clearing empties an optional field.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
|
||||||
|
**1. Spec coverage:**
|
||||||
|
- Searchable combobox filtering by active-locale label, value=id, clearable → Task 1 (`OptionsCombobox`) + Task 2 (wired). ✓
|
||||||
|
- Base UI `combobox` primitive, no new dep → Task 1 `ui/combobox.tsx`. ✓
|
||||||
|
- `useTerms`/`useAuthorities` unchanged (client-side) → Task 2 leaves them untouched. ✓
|
||||||
|
- Tests open/filter/select/clear + story → Task 1 Steps 3/5, Task 2 Step 4. ✓
|
||||||
|
- Bundle ≤150 KB gz index, typecheck/lint/test/build/parity/codename → Task 3. ✓
|
||||||
|
- Out of scope (server-side `?q=`, selected-id→label resolution, multi-select) → not implemented; filed as follow-up by the controller. ✓
|
||||||
|
|
||||||
|
**2. Placeholder scan:** No "TBD"/"handle errors"/"similar to". Concrete code for the wrapper, the component, the test, and the story. The few "verify against the d.ts / confirm by running" notes target the one genuinely novel piece (the repo's first Base UI Combobox) and are verification steps, not deferred implementation — the canonical composition is given in the header.
|
||||||
|
|
||||||
|
**3. Type consistency:** `Option = { id, labels }`; `OptionsCombobox` prop contract matches the old `OptionsSelect` exactly (`id/value/onChange/options/lang/placeholder`); `value` (id string) ↔ Base UI item object via `find`/`?.id`. `labelIn` is defined once after Task 2 (exported from `options-combobox.tsx` or `lib/labels.ts`). Wrapper part names match the canonical Base UI tree (Root/InputGroup/Input/Clear/Trigger/Portal/Positioner/Popup/List/Item/Empty).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- No new npm dependency (`@base-ui/react` already present) → no `pnpm-lock.yaml` churn expected.
|
||||||
|
- The popup is portaled — every test/story that interacts with options must query `within(document.body)`, not `canvas` alone.
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# Wire the Spectrum Seed into Runtime Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Expose the existing idempotent `db::seed::seed_spectrum_cataloguing` as a `server seed` CLI subcommand (plus a `just seed` recipe and README note), so an operator can seed an instance's baseline cataloguing fields.
|
||||||
|
|
||||||
|
**Architecture:** Mirror the existing `create-user` one-shot exactly — add a `Seed` variant to the clap `Command` enum, dispatch it to a new `server::seed(database_url)` that connects with a tiny pool, applies migrations (idempotent, so it works on a fresh DB), runs the seed inside a transaction, commits, and exits. The seed content and its idempotency are already tested at the db layer; the new code is thin glue.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (clap derive, sqlx/Postgres, anyhow, tokio). Backend-only + docs.
|
||||||
|
|
||||||
|
**Conventions:** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets -- -D warnings`; tests via `cargo nextest run`; never write the codename ("biggus"/"dickus"). Test infra: compose Postgres on host **5442**, Meili **7700**; `#[sqlx::test(migrations = "../db/migrations")]` provisions its own temp DB. Env for manual runs comes from `.env` via the justfile's `set dotenv-load`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-05-spectrum-seed-wiring-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `crates/server/src/main.rs` — add a `Seed` variant to the `Command` enum + a dispatch arm.
|
||||||
|
- `crates/server/src/lib.rs` — add `pub async fn seed(database_url: &str) -> anyhow::Result<()>` (modeled on `create_user`, but with a `db.migrate()` step).
|
||||||
|
- `crates/server/tests/seed.rs` (new) — a server-crate building-block regression test mirroring `crates/server/tests/create_user.rs` (seed twice via the test pool; assert a known seeded vocabulary + field).
|
||||||
|
- `justfile` — add a `seed` recipe.
|
||||||
|
- `README.md` — add a seed step to the "Running locally" setup sequence.
|
||||||
|
|
||||||
|
The seed *content* + idempotency stay covered by the existing `crates/db/tests/seed.rs` (unchanged).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `server seed` subcommand
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/server/src/main.rs`
|
||||||
|
- Modify: `crates/server/src/lib.rs`
|
||||||
|
- Create: `crates/server/tests/seed.rs`
|
||||||
|
|
||||||
|
**Reference (the template to mirror) — `server::create_user` in `crates/server/src/lib.rs`:**
|
||||||
|
```rust
|
||||||
|
pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow::Result<()> {
|
||||||
|
// ...email parse + password hash...
|
||||||
|
let db = Db::connect(database_url, 2).await.context("connecting to the database")?;
|
||||||
|
let mut tx = db.pool().begin().await?;
|
||||||
|
let id = db::users::create_user(&mut tx, AuditActor::System, &NewUser { /* ... */ }).await
|
||||||
|
.context("creating the user (is the email already taken?)")?;
|
||||||
|
tx.commit().await?;
|
||||||
|
println!("created user {id} ({role:?})");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`Db::connect(url, n)`, `db.migrate()`, `db.pool()` all already exist (`run` calls `db.migrate()` at `lib.rs:22`). The seed fn `db::seed::seed_spectrum_cataloguing(conn: &mut sqlx::PgConnection)` is idempotent and uses `AuditActor::System` internally — no actor plumbing needed.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the server-crate building-block test.** Create `crates/server/tests/seed.rs`. Mirror the harness comment + pool approach from `crates/server/tests/create_user.rs` (the temp-DB URL isn't exposed, so we exercise the building block the command composes — `db::seed::seed_spectrum_cataloguing` — against the test pool, including a second run to prove idempotency):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use db::{Db, fields, seed, vocab};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
// Note: `server::seed` opens its own DB connection by URL, but `#[sqlx::test]`
|
||||||
|
// provisions a temporary database whose URL is not directly exposed. This test
|
||||||
|
// exercises the building block the command composes — `db::seed::seed_spectrum_cataloguing`
|
||||||
|
// — against the test pool, run twice to prove the idempotency the command relies on.
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn seed_is_idempotent_via_building_block(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
|
for _ in 0..2 {
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
seed::seed_spectrum_cataloguing(&mut tx).await.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A representative seeded vocabulary and field definition are present after two runs.
|
||||||
|
assert!(
|
||||||
|
vocab::vocabulary_by_key(db.pool(), "material").await.unwrap().is_some(),
|
||||||
|
"vocabulary 'material' should be seeded"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fields::field_definition_by_key(db.pool(), "title").await.unwrap().is_some(),
|
||||||
|
"field definition 'title' should be seeded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Confirm the seeded keys by reading `crates/db/src/seed.rs` — it seeds vocabularies `material`/`object_name`/`technique` and a field def `title`; adjust the asserted keys if they differ.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test — it should PASS immediately.**
|
||||||
|
```
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo nextest run -p server -E 'test(seed_is_idempotent_via_building_block)'
|
||||||
|
```
|
||||||
|
Expected: PASS. (Unlike classic TDD, this guards an already-working building block the new command depends on — there is no failing-first state because `db::seed` already exists. The genuinely new code is the glue in Steps 3–4, verified by build + the manual smoke in Step 6.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the `Seed` command variant + dispatch** in `crates/server/src/main.rs`. Add to the `Command` enum (after `CreateUser { … }`):
|
||||||
|
```rust
|
||||||
|
/// Seed the baseline Spectrum cataloguing vocabularies + field definitions (idempotent).
|
||||||
|
Seed,
|
||||||
|
```
|
||||||
|
And add a match arm in `main` (the `match cli.command { … }`), after the `CreateUser` arm:
|
||||||
|
```rust
|
||||||
|
Some(Command::Seed) => seed(&cli.config.database_url).await,
|
||||||
|
```
|
||||||
|
Update the import at the top of `main.rs` from `use server::{Config, create_user, run};` to:
|
||||||
|
```rust
|
||||||
|
use server::{Config, create_user, run, seed};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the `seed` one-shot** in `crates/server/src/lib.rs`, next to `create_user`:
|
||||||
|
```rust
|
||||||
|
/// One-shot: apply migrations (idempotent), then seed the baseline Spectrum cataloguing
|
||||||
|
/// vocabularies + field definitions. Safe to re-run (the seed is idempotent).
|
||||||
|
pub async fn seed(database_url: &str) -> anyhow::Result<()> {
|
||||||
|
// CLI one-shot: a tiny pool is plenty.
|
||||||
|
let db = Db::connect(database_url, 2)
|
||||||
|
.await
|
||||||
|
.context("connecting to the database")?;
|
||||||
|
|
||||||
|
// Apply migrations first so `server seed` works on a fresh DB without first
|
||||||
|
// starting the server. Migrations are idempotent.
|
||||||
|
db.migrate()
|
||||||
|
.await
|
||||||
|
.context("running database migrations")?;
|
||||||
|
|
||||||
|
let mut tx = db.pool().begin().await?;
|
||||||
|
|
||||||
|
db::seed::seed_spectrum_cataloguing(&mut tx)
|
||||||
|
.await
|
||||||
|
.context("seeding Spectrum cataloguing baseline")?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
println!("seeded Spectrum cataloguing baseline (idempotent)");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(`Db`, `anyhow::Context`/`context` are already imported in `lib.rs` — verify the `use` lines; `create_user` already uses `.context(...)` and `Db::connect`, so the imports exist.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build, fmt, clippy, and run the server tests.**
|
||||||
|
```
|
||||||
|
cargo +nightly fmt
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo nextest run -p server
|
||||||
|
```
|
||||||
|
Expected: builds clean, clippy clean, all server tests pass (including the existing `create_user` + `config` + `serve` + `embed` tests and the new seed test). Also confirm the subcommand is wired:
|
||||||
|
```
|
||||||
|
cargo run -p server -- --help
|
||||||
|
```
|
||||||
|
Expected: the help output lists a `seed` subcommand alongside `create-user`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Manual smoke — verify the real command (connect + migrate + commit glue).** With compose up (`docker compose up -d`):
|
||||||
|
```
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo run -p server -- seed
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo run -p server -- seed
|
||||||
|
```
|
||||||
|
Expected: both print `seeded Spectrum cataloguing baseline (idempotent)` and exit 0 (the second run is a no-op). (This exercises the URL-connect + migrate + commit path that `#[sqlx::test]` can't.)
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit.**
|
||||||
|
```bash
|
||||||
|
git add crates/server
|
||||||
|
git commit -m "feat(server): 'seed' subcommand wiring the Spectrum cataloguing seed (#14)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `just seed` recipe + README note
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `justfile`
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the `seed` recipe** to `justfile`. Insert after the `run` recipe (keeping the existing comment style), before `test`:
|
||||||
|
```
|
||||||
|
# Seed the baseline Spectrum cataloguing vocabularies + field definitions (idempotent)
|
||||||
|
seed:
|
||||||
|
cargo run -p server -- seed
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify just parses it.**
|
||||||
|
```
|
||||||
|
just --list
|
||||||
|
```
|
||||||
|
Expected: `seed` appears in the recipe list with its description.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add a seed step to the README "Running locally" setup sequence.** Open `README.md`, find the "Running locally" section and the step that creates the admin user (the `create-user` instruction). Immediately after it, add a step:
|
||||||
|
```markdown
|
||||||
|
4. Seed the baseline cataloguing fields (idempotent):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just seed # or: cargo run -p server -- seed
|
||||||
|
```
|
||||||
|
```
|
||||||
|
(Match the surrounding numbering/formatting of the existing steps — renumber subsequent steps if the section is numbered. Read the section first and adapt the wording to its style; the content is: run `just seed` once after creating the admin user to populate the baseline Spectrum vocabularies + field definitions.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit.**
|
||||||
|
```bash
|
||||||
|
git add justfile README.md
|
||||||
|
git commit -m "docs: 'just seed' recipe + README seed step (#14)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Final verification
|
||||||
|
|
||||||
|
**Files:** none (verification only).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full suite + lints.**
|
||||||
|
```
|
||||||
|
cargo +nightly fmt --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo nextest run --workspace
|
||||||
|
```
|
||||||
|
Expected: all green.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Codename scan + tree hygiene.**
|
||||||
|
```
|
||||||
|
git grep -in 'biggus\|dickus' -- crates README.md justfile || echo "CLEAN"
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
Expected: `CLEAN`; working tree clean after the task commits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
|
||||||
|
**1. Spec coverage:**
|
||||||
|
- `server seed` subcommand → Task 1 (main.rs variant + dispatch). ✓
|
||||||
|
- `server::seed` one-shot mirroring create_user, migrate-first → Task 1 Step 4. ✓
|
||||||
|
- Idempotent / safe to re-run → asserted in Task 1 Step 1 test + Step 6 smoke. ✓
|
||||||
|
- `just seed` recipe + README note → Task 2. ✓
|
||||||
|
- Testing: existing db-layer seed tests unchanged + new server-crate building-block test + manual glue smoke → Task 1. ✓
|
||||||
|
- Acceptance: nextest green / fmt / clippy / no codename → Task 3. ✓
|
||||||
|
- Out of scope (no `--seed` flag, no auto-boot, no provisioning, no term seeding, create_user unchanged) → respected; only the four files above change. ✓
|
||||||
|
|
||||||
|
**2. Placeholder scan:** No TBD/“handle errors”/“similar to”. The two “confirm the seeded keys / read the section first” notes are verification steps against real files, not deferred implementation; concrete code is given for every code step.
|
||||||
|
|
||||||
|
**3. Type consistency:** `seed(database_url: &str) -> anyhow::Result<()>` is defined in Task 1 Step 4 and imported/dispatched in Step 3 (`use server::{… seed}`, `Some(Command::Seed) => seed(&cli.config.database_url).await`). The test uses `db::seed::seed_spectrum_cataloguing(&mut tx)` + `vocab::vocabulary_by_key` + `fields::field_definition_by_key`, all existing signatures (mirrored from `crates/db/tests/seed.rs` and `create_user.rs`).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- No new dependencies → no `Cargo.lock` churn expected.
|
||||||
|
- `Command::Seed` has no clap args; it reuses the flattened `Config.database_url`, exactly like `CreateUser` does.
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
# Dark-Mode Theme Toggle Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Ship a tri-state (Light/Dark/System) theme toggle that activates the existing `.dark` token set, persists to `localStorage`, defaults to System (live-tracking the OS), and never flashes on reload.
|
||||||
|
|
||||||
|
**Architecture:** Client-only theming over CSS custom properties — no new dependency. A framework-free core (`theme.ts`) resolves/reads/applies the theme; a `useTheme` hook mirrors `use-locale`; a synchronous inline script in `index.html` applies the class before first paint; an icon segmented `ThemeSwitch` lives in the header next to `LangSwitch`. The `.dark` class on `<html>` activates the dark tokens migrated in #49.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19 + TS + pnpm, Tailwind v4 (OKLCH tokens in `index.css`), lucide-react (already a dep), Vitest + RTL + MSW + Storybook. Test runner: `pnpm test` (vitest, single pass).
|
||||||
|
|
||||||
|
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity; source double-quote/semicolon, stories single-quote/no-semicolon; token classes only (no raw colors — `check:colors` must pass); guard DOM globals (`window`/`localStorage`/`matchMedia`/`document`) for jsdom/test safety.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-07-dark-mode-theme-toggle-design.md`
|
||||||
|
|
||||||
|
**File structure:**
|
||||||
|
- `web/src/theme/theme.ts` (new) — `THEME_KEY`, `Theme`, `resolveTheme`, `readTheme`, `applyTheme`.
|
||||||
|
- `web/src/theme/theme.test.ts` (new) — unit tests for the core.
|
||||||
|
- `web/src/theme/use-theme.ts` (new) — `useTheme()` hook.
|
||||||
|
- `web/src/shell/theme-switch.tsx` (new) — the icon segmented control.
|
||||||
|
- `web/src/shell/theme-switch.test.tsx` (new) — interaction tests.
|
||||||
|
- `web/src/shell/theme-switch.stories.tsx` (new) — Storybook story.
|
||||||
|
- `web/src/shell/app-shell.tsx` (modify) — mount `<ThemeSwitch />`.
|
||||||
|
- `web/src/i18n/en.json`, `web/src/i18n/sv.json` (modify) — `theme.*` keys.
|
||||||
|
- `web/index.html` (modify) — inline FOUC-prevention script.
|
||||||
|
- `web/src/index.css` (modify) — dark `--primary`/`--ring` contrast tweak.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 1: Theme core (`theme.ts`) + unit tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/theme/theme.ts`
|
||||||
|
- Create: `web/src/theme/theme.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests** — `web/src/theme/theme.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { applyTheme, readTheme, resolveTheme, THEME_KEY } from "./theme";
|
||||||
|
|
||||||
|
function mockMatchMedia(matches: boolean) {
|
||||||
|
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||||
|
matches,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
localStorage.clear();
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveTheme returns explicit values verbatim", () => {
|
||||||
|
expect(resolveTheme("light")).toBe("light");
|
||||||
|
expect(resolveTheme("dark")).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveTheme maps system via prefers-color-scheme", () => {
|
||||||
|
mockMatchMedia(true);
|
||||||
|
expect(resolveTheme("system")).toBe("dark");
|
||||||
|
mockMatchMedia(false);
|
||||||
|
expect(resolveTheme("system")).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readTheme defaults to system when unset or invalid", () => {
|
||||||
|
expect(readTheme()).toBe("system");
|
||||||
|
localStorage.setItem(THEME_KEY, "bogus");
|
||||||
|
expect(readTheme()).toBe("system");
|
||||||
|
localStorage.setItem(THEME_KEY, "dark");
|
||||||
|
expect(readTheme()).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyTheme toggles the dark class on documentElement", () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
applyTheme("dark");
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
applyTheme("light");
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||||
|
mockMatchMedia(true);
|
||||||
|
applyTheme("system");
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm vitest run src/theme/theme.test.ts`
|
||||||
|
Expected: FAIL — cannot import from `./theme` (module not found).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — `web/src/theme/theme.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const THEME_KEY = "theme";
|
||||||
|
|
||||||
|
export type Theme = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
const THEMES: readonly Theme[] = ["light", "dark", "system"];
|
||||||
|
|
||||||
|
function prefersDark(): boolean {
|
||||||
|
return (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
typeof window.matchMedia === "function" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTheme(theme: Theme): "light" | "dark" {
|
||||||
|
if (theme === "light" || theme === "dark") return theme;
|
||||||
|
return prefersDark() ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTheme(): Theme {
|
||||||
|
if (typeof localStorage === "undefined") return "system";
|
||||||
|
const stored = localStorage.getItem(THEME_KEY);
|
||||||
|
return THEMES.includes(stored as Theme) ? (stored as Theme) : "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme: Theme): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
document.documentElement.classList.toggle("dark", resolveTheme(theme) === "dark");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm vitest run src/theme/theme.test.ts`
|
||||||
|
Expected: PASS (4 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/theme/theme.ts web/src/theme/theme.test.ts
|
||||||
|
git commit -m "feat(web): theme core — resolve/read/apply tri-state theme (#59)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 2: `useTheme` hook
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/theme/use-theme.ts`
|
||||||
|
|
||||||
|
(No standalone unit test — the hook is exercised by `theme-switch.test.tsx` in Task 3, which drives it through real UI per the project's testing style. `theme.ts` carries the logic and is unit-tested in Task 1.)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement** — `web/src/theme/use-theme.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { applyTheme, readTheme, type Theme } from "./theme";
|
||||||
|
|
||||||
|
export function useTheme(): { theme: Theme; setTheme: (theme: Theme) => void } {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(readTheme);
|
||||||
|
|
||||||
|
const setTheme = (next: Theme) => {
|
||||||
|
if (typeof localStorage !== "undefined") localStorage.setItem("theme", next);
|
||||||
|
setThemeState(next);
|
||||||
|
applyTheme(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(theme);
|
||||||
|
if (theme !== "system") return;
|
||||||
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||||
|
const mql = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const onChange = () => applyTheme("system");
|
||||||
|
mql.addEventListener("change", onChange);
|
||||||
|
return () => mql.removeEventListener("change", onChange);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return { theme, setTheme };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: import `THEME_KEY` from `./theme` and use it instead of the literal `"theme"` for the
|
||||||
|
`localStorage.setItem` key (DRY with the core). Update the import line to
|
||||||
|
`import { applyTheme, readTheme, THEME_KEY, type Theme } from "./theme";` and use
|
||||||
|
`localStorage.setItem(THEME_KEY, next)`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Typecheck**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm typecheck`
|
||||||
|
Expected: PASS (no errors).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/theme/use-theme.ts
|
||||||
|
git commit -m "feat(web): useTheme hook with live system tracking (#59)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 3: `ThemeSwitch` UI + i18n + tests + story
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/shell/theme-switch.tsx`
|
||||||
|
- Create: `web/src/shell/theme-switch.test.tsx`
|
||||||
|
- Create: `web/src/shell/theme-switch.stories.tsx`
|
||||||
|
- Modify: `web/src/i18n/en.json`, `web/src/i18n/sv.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add i18n keys.** In `web/src/i18n/en.json`, add a top-level `theme` namespace (place after the `labels` entry):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"theme": { "light": "Light", "dark": "Dark", "system": "System" },
|
||||||
|
```
|
||||||
|
|
||||||
|
In `web/src/i18n/sv.json`, the matching entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"theme": { "light": "Ljust", "dark": "Mörkt", "system": "System" },
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing test** — `web/src/shell/theme-switch.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { ThemeSwitch } from "./theme-switch";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
localStorage.clear();
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting Dark applies the dark class and persists", async () => {
|
||||||
|
renderApp(<ThemeSwitch />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /dark/i }));
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
expect(localStorage.getItem("theme")).toBe("dark");
|
||||||
|
expect(screen.getByRole("button", { name: /dark/i })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting Light removes the dark class and persists", async () => {
|
||||||
|
localStorage.setItem("theme", "dark");
|
||||||
|
renderApp(<ThemeSwitch />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /light/i }));
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||||
|
expect(localStorage.getItem("theme")).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting System resolves via prefers-color-scheme", async () => {
|
||||||
|
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||||
|
matches: true,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
renderApp(<ThemeSwitch />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /system/i }));
|
||||||
|
expect(localStorage.getItem("theme")).toBe("system");
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm vitest run src/shell/theme-switch.test.tsx`
|
||||||
|
Expected: FAIL — cannot import `ThemeSwitch`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement** — `web/src/shell/theme-switch.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Monitor, Moon, Sun } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useTheme } from "../theme/use-theme";
|
||||||
|
import type { Theme } from "../theme/theme";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const OPTIONS: { value: Theme; Icon: typeof Sun }[] = [
|
||||||
|
{ value: "light", Icon: Sun },
|
||||||
|
{ value: "dark", Icon: Moon },
|
||||||
|
{ value: "system", Icon: Monitor },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ThemeSwitch() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{OPTIONS.map(({ value, Icon }) => {
|
||||||
|
const active = theme === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme(value)}
|
||||||
|
aria-pressed={active}
|
||||||
|
aria-label={t(`theme.${value}`)}
|
||||||
|
title={t(`theme.${value}`)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md p-1 transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-accent text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Verify the `cn` import path matches the project — other `ui/*` files import `cn` from `@/lib/utils`. If `lib/utils` is absent, mirror whatever `button.tsx` uses.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm vitest run src/shell/theme-switch.test.tsx`
|
||||||
|
Expected: PASS (3 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Write the Storybook story** — `web/src/shell/theme-switch.stories.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||||
|
import { expect } from 'storybook/test'
|
||||||
|
|
||||||
|
import { ThemeSwitch } from './theme-switch'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
component: ThemeSwitch,
|
||||||
|
tags: ['ai-generated'],
|
||||||
|
} satisfies Meta<typeof ThemeSwitch>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
play: async ({ canvas }) => {
|
||||||
|
await expect(canvas.getByRole('button', { name: /light/i })).toBeInTheDocument()
|
||||||
|
await expect(canvas.getByRole('button', { name: /dark/i })).toBeInTheDocument()
|
||||||
|
await expect(canvas.getByRole('button', { name: /system/i })).toBeInTheDocument()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Note: the story exercises rendering only — it does not click options, to avoid mutating `<html>`
|
||||||
|
globally across the browser-mode test run.)
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run the story as a test + lint**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm vitest run src/shell/theme-switch.stories.tsx && pnpm lint`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/shell/theme-switch.tsx web/src/shell/theme-switch.test.tsx web/src/shell/theme-switch.stories.tsx web/src/i18n/en.json web/src/i18n/sv.json
|
||||||
|
git commit -m "feat(web): ThemeSwitch icon segmented control + theme.* i18n (#59)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 4: Mount in the header + FOUC inline script
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/shell/app-shell.tsx`
|
||||||
|
- Modify: `web/index.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Mount `ThemeSwitch`.** In `web/src/shell/app-shell.tsx`, add the import:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemeSwitch } from "./theme-switch";
|
||||||
|
```
|
||||||
|
|
||||||
|
and render it in the header immediately before `<LangSwitch />`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex-1" />
|
||||||
|
<ThemeSwitch />
|
||||||
|
<LangSwitch />
|
||||||
|
```
|
||||||
|
|
||||||
|
(Match the existing header's exact JSX; only insert the one line. Do not change other markup.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the FOUC-prevention inline script.** In `web/index.html`, inside `<head>`
|
||||||
|
BEFORE the `<script type="module" src="/src/main.tsx">` tag, add:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem("theme") || "system";
|
||||||
|
var dark =
|
||||||
|
t === "dark" ||
|
||||||
|
(t === "system" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
document.documentElement.classList.toggle("dark", dark);
|
||||||
|
} catch (e) {}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the app-shell test still passes** (the header now has an extra control):
|
||||||
|
|
||||||
|
Run: `cd web && pnpm vitest run src/shell/app-shell.test.tsx`
|
||||||
|
Expected: PASS (the existing "language switch" test is unaffected — ThemeSwitch buttons have distinct accessible names).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build to verify `index.html` is valid**
|
||||||
|
|
||||||
|
Run: `cd web && pnpm build`
|
||||||
|
Expected: built successfully (Vite processes the inline script).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/shell/app-shell.tsx web/index.html
|
||||||
|
git commit -m "feat(web): mount ThemeSwitch in header + pre-paint theme init (#59)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 5: Dark `--primary` contrast tweak + final verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/index.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Compute the new dark `--primary`.** The dark button label uses `--primary-foreground:
|
||||||
|
oklch(0.205 0 0)` (near-black) on `--primary: oklch(0.673 0.182 276.935)` (~3.21:1). Lower the
|
||||||
|
lightness (and keep it a recognizable indigo) until WCAG contrast vs `oklch(0.205 0 0)` is **≥4.5:1**.
|
||||||
|
A good starting point is `oklch(0.62 0.20 277)`; compute the exact value with a contrast check
|
||||||
|
(convert both to sRGB relative luminance, `(L1+0.05)/(L2+0.05) ≥ 4.5`). In the `.dark` block of
|
||||||
|
`web/src/index.css`, update BOTH `--primary` and `--ring` (they must match) to the chosen value:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--primary: oklch(<chosen-L> <chosen-C> 277);
|
||||||
|
...
|
||||||
|
--ring: oklch(<chosen-L> <chosen-C> 277);
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave `--primary-foreground: oklch(0.205 0 0)` and the entire `:root` (light) block unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the contrast.** State the computed ratio in the commit body (must be ≥4.5:1).
|
||||||
|
Sanity-check the value is still visibly indigo (hue ~277, chroma not flattened to gray).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Full gate (single test pass).**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
|
||||||
|
```
|
||||||
|
Expected: all green. `check:colors` passes (icons are not color utilities). `check:size` within 250 KB
|
||||||
|
gz (three lucide icons are negligible). Tests run exactly ONCE (no concurrent runs).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Codename + status checks.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -in 'biggus\|dickus' -- web/src web/index.html; echo "codename-exit=$?"
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
Expected: no codename matches; working tree shows only intended changes.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual smoke (recommended).** `pnpm dev`, toggle Light/Dark/System; confirm the app
|
||||||
|
switches, a dark reload doesn't flash light, primary buttons are legible in dark, and switching the
|
||||||
|
OS theme while in System updates the app live.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/index.css
|
||||||
|
git commit -m "fix(web): raise dark --primary contrast to AA for button labels (#59)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
|
||||||
|
**Spec coverage:** tri-state model + System default (T1 `resolveTheme`/`readTheme`, T3 UI); persisted
|
||||||
|
to localStorage (T2 `setTheme`, T3 tests); `.dark` on `<html>` (T1 `applyTheme`); live system tracking
|
||||||
|
(T2 `useEffect` matchMedia listener); FOUC prevention (T4 inline script); icon segmented control next
|
||||||
|
to LangSwitch (T3 + T4 mount); en/sv `theme.*` (T3); aria-pressed/aria-label (T3); dark `--primary`
|
||||||
|
contrast ≥4.5:1 + `--ring` sync (T5); gate incl. check:colors/check:size + no codename + no new dep
|
||||||
|
(T5). All acceptance criteria 1–6 mapped. ✓
|
||||||
|
|
||||||
|
**Placeholder scan:** the only "computed" value is the exact dark `--primary` OKLCH — a genuine WCAG
|
||||||
|
measurement step with a concrete starting point and an explicit acceptance threshold (≥4.5:1), not a
|
||||||
|
TODO. All code blocks are complete. ✓
|
||||||
|
|
||||||
|
**Type consistency:** `Theme` type defined in `theme.ts` (T1), imported by `use-theme.ts` (T2) and
|
||||||
|
`theme-switch.tsx` (T3); `THEME_KEY` from `theme.ts` used in T2's setter; `resolveTheme`/`readTheme`/
|
||||||
|
`applyTheme` signatures consistent across tasks; i18n keys `theme.light/dark/system` defined in T3 and
|
||||||
|
referenced by `t(\`theme.${value}\`)` in T3's component. ✓
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- No new dependency (lucide-react already present; `.dark` tokens already exist from #49).
|
||||||
|
- The inline FOUC script is intentionally plain ES5-ish + try/catch — it runs before the bundle and
|
||||||
|
must never throw.
|
||||||
|
- Cross-tab sync and per-account/server theme default are explicit follow-ups (not in this plan).
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Design-Token Adoption Across Feature Screens — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Route every feature screen through the OKLCH design tokens — one indigo brand accent (`--primary`), token-based status colors (success/warning/highlight), the radius token, and a shared caption utility — and add a guard that keeps raw color utilities out of `src` (outside `components/ui/`).
|
||||||
|
|
||||||
|
**Architecture:** Pure styling refactor. Phase 1 adds/changes tokens + `ui` Badge variants + the visibility badge / highlight / caption helpers. Phase 2 mechanically migrates ~120 raw utilities across 27 files to tokens + the radius token. Phase 3 adds the `check:colors` guard (which can only pass once the migration is complete) and runs the gate. No behavior, layout, routing, API, or data changes.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19 + TS + pnpm, Tailwind v4 (OKLCH tokens in `index.css`), Base UI, Vitest+RTL+MSW (incl. Storybook browser project).
|
||||||
|
|
||||||
|
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv untouched (no strings); `check:size` budget 250 KB gz (no real change expected). Stories single-quote/no-semicolon; source double-quote/semicolon. **Do not change markup/layout/spacing** — only color/radius utilities + Badge variant selection.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-07-design-token-adoption-design.md`
|
||||||
|
|
||||||
|
**Migration surface (27 files with raw color utilities, outside `components/ui/`):** `app.tsx`, `auth/login-page.tsx`, `authorities/authorities-page.tsx`, `components/delete-confirm-dialog.tsx`, `fields/field-form.tsx`, `fields/field-list.tsx`, `objects/{delete-object-dialog,flexible-field-value,object-detail-drawer,object-detail,object-edit-form,object-form,objects-page,objects-table,options-combobox,publish-control,visibility-badge,visibility-badge.stories}.tsx`, `search/{highlight,search-panel,search-result-row,select-search-prompt}.tsx`, `shell/{lang-switch,sidebar}.tsx`, `vocab/{select-vocabulary-prompt,vocabulary-list,vocabulary-terms}.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 1: Token + component foundation
|
||||||
|
**Files:** `web/src/index.css`, `web/src/components/ui/badge.tsx` (+ `badge.stories.tsx` if present), `web/src/objects/visibility-badge.tsx`, `web/src/objects/visibility-badge.stories.tsx`, `web/src/search/highlight.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Indigo primary + status tokens** in `web/src/index.css`. In `:root`:
|
||||||
|
```css
|
||||||
|
--primary: oklch(0.511 0.262 276.966); /* indigo-600 */
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--ring: oklch(0.511 0.262 276.966);
|
||||||
|
--success: oklch(0.627 0.194 149.214); /* green-600 — readable as text */
|
||||||
|
--success-foreground: oklch(0.985 0 0);
|
||||||
|
--warning: oklch(0.666 0.179 58.318); /* amber-700-ish — readable as text */
|
||||||
|
--warning-foreground: oklch(0.985 0 0);
|
||||||
|
--highlight: oklch(0.905 0.182 98.111); /* ~yellow-300 search highlight */
|
||||||
|
--highlight-foreground: oklch(0.205 0 0);
|
||||||
|
```
|
||||||
|
In `.dark` (keep coherent for #59): `--primary: oklch(0.673 0.182 276.935)` (indigo-400), `--primary-foreground: oklch(0.205 0 0)`, `--ring` to match; `--success`/`--warning` slightly lighter for dark; `--highlight` unchanged or darker-text. In `@theme inline` add the `--color-*` mappings: `--color-success: var(--success); --color-success-foreground: var(--success-foreground); --color-warning: var(--warning); --color-warning-foreground: var(--warning-foreground); --color-highlight: var(--highlight); --color-highlight-foreground: var(--highlight-foreground);`. Add a shared caption utility in `@layer components`:
|
||||||
|
```css
|
||||||
|
@layer components {
|
||||||
|
.label-caption { @apply text-xs font-medium uppercase tracking-wide text-muted-foreground; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Implementer may fine-tune the oklch to match exact Tailwind shades; keep `*-foreground` contrast ≥ AA.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Badge variants.** In `web/src/components/ui/badge.tsx`, add to the `cva` variants (mirror the `destructive` shape):
|
||||||
|
```ts
|
||||||
|
success:
|
||||||
|
"bg-success/10 text-success [a]:hover:bg-success/20",
|
||||||
|
warning:
|
||||||
|
"bg-warning/10 text-warning [a]:hover:bg-warning/20",
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: VisibilityBadge → variants.** In `web/src/objects/visibility-badge.tsx`, replace the hardcoded `STYLES` (amber/green/neutral) with variant selection:
|
||||||
|
```tsx
|
||||||
|
const VARIANT: Record<Visibility, "secondary" | "warning" | "success"> = {
|
||||||
|
draft: "secondary",
|
||||||
|
internal: "warning",
|
||||||
|
public: "success",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VisibilityBadge({ visibility }: { visibility: Visibility }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <Badge variant={VARIANT[visibility]}>{t(`visibility.${visibility}`)}</Badge>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Drop the `variant="outline" className={STYLES[...]}` patching.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Highlight token.** In `web/src/search/highlight.tsx`, `bg-yellow-200` → `bg-highlight text-highlight-foreground`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update stories.** Add `Success`/`Warning` stories to the Badge story file (if `badge.stories.tsx` exists; else create alongside). **Update the `CssCheck` story** in `visibility-badge.stories.tsx`: it asserts the public badge background `oklch(0.962 0.044 156.743)` (old green-100). Public is now the `success` variant (`bg-success/10`). **Run the story, read the new `getComputedStyle(...).backgroundColor`, and pin that value** (keep the CssCheck — it proves Tailwind + tokens load). Update the comment.
|
||||||
|
|
||||||
|
- [ ] **Step 6:** `cd web && pnpm test -- visibility-badge badge && pnpm typecheck && pnpm lint`. The visibility badge renders with token colors; CssCheck passes with the new value. **Commit** `feat(web): indigo brand token + status tokens + Badge success/warning variants (#49)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 2: Migrate feature screens to tokens + radius
|
||||||
|
**Files:** the 27 migration-surface files listed above (excluding `visibility-badge.tsx`/`.stories.tsx` + `highlight.tsx` done in Task 1).
|
||||||
|
|
||||||
|
Apply the migration map mechanically. **Use the guard regex (Task 3) as your completeness checker**: after migrating, `grep -rE "(text|bg|border|ring)-(neutral|gray|slate|red|amber|green|yellow|indigo|…)-[0-9]+" src --include="*.tsx" | grep -v "components/ui/"` must return **nothing**.
|
||||||
|
|
||||||
|
| From | To |
|
||||||
|
|---|---|
|
||||||
|
| `text-red-600` | `text-destructive` |
|
||||||
|
| `text-neutral-400` / `-500` / `-600` | `text-muted-foreground` |
|
||||||
|
| `text-neutral-700` / `-900` | `text-foreground` |
|
||||||
|
| `bg-neutral-50` / `-100` | `bg-muted` |
|
||||||
|
| `bg-neutral-200` (active nav, sidebar) | `bg-accent` |
|
||||||
|
| `bg-indigo-50` (selected row) | `bg-primary/10` |
|
||||||
|
| `bg-indigo-600` / `text-indigo-600` | `bg-primary` / `text-primary` (+ `text-primary-foreground` where on `bg-primary`) |
|
||||||
|
| `bg-neutral-800` (publish stepper / authority tabs active) | `bg-primary text-primary-foreground` |
|
||||||
|
| `border-red-300` (combobox/drawer error) | `border-destructive` (or keep neutral `border` if it's not an error state) |
|
||||||
|
| `border-green-300` | `border-success` (or neutral) |
|
||||||
|
| bare `rounded` (×23) | `rounded-md` |
|
||||||
|
|
||||||
|
- [ ] **Step 1: Migrate by area**, file-by-file, replacing per the map. Also collapse the uppercase-caption recipes (object-detail, object-form, publish-control, field-list, vocabulary-terms) to the shared `label-caption` class (`<div className="label-caption">…`). **Do not change any non-color/radius classes, markup, or layout.** For the few ambiguous one-offs, follow the map's intent (muted captions → `text-muted-foreground`; emphasized values → `text-foreground`; error text → `text-destructive`). Optionally adopt `ui/Card` for an obviously hand-rolled bordered panel (e.g. object-detail) — only if a clean swap; skip otherwise.
|
||||||
|
- [ ] **Step 2: Completeness check** — run the grep above; iterate until **zero** raw color utilities remain outside `components/ui/`. Also confirm no bare `rounded` remains (→ `rounded-md`).
|
||||||
|
- [ ] **Step 3: Verify no regressions** — `cd web && pnpm typecheck && pnpm lint && pnpm test` (all existing tests pass; the styling change shouldn't break behavioral tests — if a test asserts a specific old color/class, update it to the token equivalent). `pnpm build`.
|
||||||
|
- [ ] **Step 4: Commit** `refactor(web): migrate feature screens to design tokens + radius token (#49)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 3: Enforcement guard + final verification
|
||||||
|
**Files:** `web/scripts/check-no-raw-colors.mjs` (new), `web/package.json` (a `check:colors` script), wire into the gate.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Guard script** `web/scripts/check-no-raw-colors.mjs` (mirror `check-bundle-size.mjs` style): recursively scan `web/src/**/*.{ts,tsx}` **excluding `src/components/ui/`**; fail (exit 1, printing each `file:line`) on any match of:
|
||||||
|
```
|
||||||
|
/(?:text|bg|border|ring|fill|stroke|from|to|via|decoration|outline|divide|placeholder)-(?:neutral|gray|slate|zinc|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950)\b/
|
||||||
|
```
|
||||||
|
Skip comments if practical; the goal is to catch real className usages. (It must NOT flag token utilities like `text-foreground`/`bg-primary` or numerics like `gap-2`.)
|
||||||
|
- [ ] **Step 2: Wire it in** — add `"check:colors": "node scripts/check-no-raw-colors.mjs"` to `web/package.json`; include it in the project's check/CI flow (e.g. the `.gitea/workflows` web job, or alongside `check:size`). Run it → it must **pass** now (Task 2 cleared the surface).
|
||||||
|
- [ ] **Step 3: Final verification:**
|
||||||
|
```
|
||||||
|
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
|
||||||
|
```
|
||||||
|
All green. `pnpm test -- i18n` (parity unaffected). `git grep -in 'biggus\|dickus' -- web/src || echo CLEAN`. `git status --short` clean.
|
||||||
|
- [ ] **Step 4: Manual smoke (recommended):** run the app — buttons/links/selected rows/active nav share the indigo accent; visibility badges (success/warning/neutral) + search highlight use the status tokens; nothing renders an unstyled/transparent element from a removed color.
|
||||||
|
- [ ] **Step 5: Commit** `chore(web): add check:colors guard banning raw color utilities outside ui/ (#49)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
**Spec coverage:** indigo `--primary`/`--ring` + status tokens + `@theme` + `.dark` (T1 S1); Badge success/warning + VisibilityBadge + highlight + label-caption (T1 S2–S4); ~120-utility migration + radius (T2); guard added last + gate (T3); CssCheck updated (T1 S5); dark-mode toggle out (#59), no behavior/layout change. ✓
|
||||||
|
**Placeholder scan:** concrete token values, badge variants, VisibilityBadge code, guard regex, and the explicit migration map + 27-file list. The CssCheck new value is "run to read" (the original story did the same — a genuine measurement step, not a placeholder). The few "ambiguous one-off" mappings are governed by the map's stated intent.
|
||||||
|
**Type/consistency:** `success`/`warning` Badge variants (T1) consumed by `VisibilityBadge` `VARIANT` map; `--color-success/warning/highlight` tokens (T1) back `bg-success`/`bg-warning`/`bg-highlight`; the guard regex (T3) matches exactly the palette utilities the migration (T2) removes.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- No new dependency; CSS token churn only → `check:size` ≈ unchanged.
|
||||||
|
- The guard is the durable win — it makes the consistency self-enforcing (closes the loop that caused #49).
|
||||||
|
- If a behavioral test asserts an old raw class/color, update it to the token equivalent (don't weaken it).
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
# App Header Wayfinding Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Fill the empty app header with wayfinding — a route-driven breadcrumb (left), a signed-in user menu + compact global search (right) — and render the configured `app_name` for the brand + login.
|
||||||
|
|
||||||
|
**Architecture:** A page-driven breadcrumb (a `BreadcrumbProvider` context + `useBreadcrumb(trail)` hook, parallel to #57's `useDocumentTitle`) that each route sets and the header renders. A reusable `ui/menu.tsx` Base UI Menu wrapper powers a `UserMenu` (email/role + Sign out). A `HeaderSearch` input navigates to `/search?q=`. Brand + login read `useConfig().app_name`. No new dependency.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19 + TS + pnpm, Tailwind v4, react-router 7, react-i18next, Base UI (`@base-ui/react/menu` — namespace `Menu`), lucide-react, Vitest + RTL + MSW + Storybook. Test runner: `pnpm test` (single pass).
|
||||||
|
|
||||||
|
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity; **ui/ files = no-semicolon base-nova style** (match `alert-dialog.tsx`); **app source (shell/, lib/, pages) = double-quote + semicolon**; stories = single-quote + no-semicolon; token classes only (`check:colors`); guard DOM globals.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-07-header-wayfinding-design.md`
|
||||||
|
|
||||||
|
**Key facts (verified):** `useMe()` (`api/queries.ts:30`) → `UserView | null` = `{ email, id, role }`. `useLogout()` (`queries.ts:129`). `useVocabularies()` (`queries.ts:258`) → `VocabularyView[]` with `.key` (the display name). Current logout flow in `app-shell.tsx`: `logout.mutate(undefined, { onSuccess: () => navigate("/login", { replace: true }) })`. Base UI render-prop pattern: see `ui/alert-dialog.tsx` (namespace import, `data-slot`, `cn()`).
|
||||||
|
|
||||||
|
**File structure:**
|
||||||
|
- `web/src/components/ui/menu.tsx` (new) + `menu.stories.tsx` (new)
|
||||||
|
- `web/src/shell/breadcrumb-context.ts` (new), `breadcrumb-provider.tsx` (new), `use-breadcrumb.ts` (new), `breadcrumb.tsx` (new render component)
|
||||||
|
- `web/src/shell/user-menu.tsx` (new), `header-search.tsx` (new)
|
||||||
|
- Modify: `web/src/shell/app-shell.tsx`, `sidebar.tsx`, `auth/login-page.tsx`, the 9 page/detail components, `i18n/en.json`, `i18n/sv.json`, `shell/app-shell.test.tsx`, `auth/login-page.test.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 1: Render `app_name` for brand + login; remove dead `app.name` key
|
||||||
|
|
||||||
|
**Files:** `web/src/shell/sidebar.tsx`, `web/src/auth/login-page.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, `web/src/auth/login-page.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Sidebar brand.** In `web/src/shell/sidebar.tsx` add `import { useConfig } from "../config/config-context";`, get `const { app_name } = useConfig();` in the component, and change line ~76:
|
||||||
|
`{!collapsed && <span className="font-semibold">{t("app.name")}</span>}` →
|
||||||
|
`{!collapsed && <span className="font-semibold">{app_name}</span>}`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Login.** In `web/src/auth/login-page.tsx`: add `import { useConfig } from "../config/config-context";`, `const { app_name } = useConfig();`. Change the `<h1>` (line ~38) to `{app_name}` and the title effect (line ~18) to `document.title = app_name;` with deps `[app_name]`. Remove the now-unused `t` for that purpose only if `t` is otherwise unused (check — login uses `t` for field labels/errors, so keep the `useTranslation` import).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove the dead i18n key.** Delete the `"app": { "name": "..." }` entry from BOTH `web/src/i18n/en.json` and `web/src/i18n/sv.json` (grep first: `grep -rn 'app\.name\|"app"' web/src` — confirm no remaining `t("app.name")` after Steps 1–2). en/sv must stay in parity (remove from both).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update login test if needed.** Read `web/src/auth/login-page.test.tsx`. If it asserts the heading text via `t("app.name")` / "Collection", update it to the config default `"Collection Management System"` (the value `useConfig` returns in tests via `DEFAULTS`). Do NOT weaken; just match the new source.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify (run vitest once for these files).**
|
||||||
|
`cd web && pnpm vitest run src/auth src/shell/app-shell.test.tsx && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS. The sidebar brand + login now show "Collection Management System" (config default) in tests.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add web/src/shell/sidebar.tsx web/src/auth/login-page.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/auth/login-page.test.tsx
|
||||||
|
git commit -m "feat(web): render configured app_name for brand + login; drop hardcoded app.name (#54)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 2: `ui/menu.tsx` Base UI Menu wrapper + story (validate by running)
|
||||||
|
|
||||||
|
**Files:** `web/src/components/ui/menu.tsx` (new), `web/src/components/ui/menu.stories.tsx` (new).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the reference** `web/src/components/ui/alert-dialog.tsx` for the exact house pattern (namespace import, `data-slot`, `cn()`, no semicolons, token classes). The Base UI Menu API is `import { Menu } from "@base-ui/react/menu"` then `Menu.Root`, `Menu.Trigger`, `Menu.Portal`, `Menu.Positioner`, `Menu.Popup`, `Menu.Item`, `Menu.Separator`. **This is novel — you MUST validate the exact part tree by running the story (Step 3).**
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement** `web/src/components/ui/menu.tsx` (no-semicolon style). Export: `Menu` (Root re-export with data-slot), `MenuTrigger`, `MenuContent` (composes Portal + Positioner + Popup), `MenuItem`, `MenuSeparator`. Skeleton (adapt class/props to what runs):
|
||||||
|
```tsx
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Menu({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
|
return <MenuPrimitive.Root data-slot="menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="menu-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 6,
|
||||||
|
align = "end",
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Popup.Props & { sideOffset?: number; align?: MenuPrimitive.Positioner.Props["align"] }) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner sideOffset={sideOffset} align={align} className="z-50">
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="menu-content"
|
||||||
|
className={cn(
|
||||||
|
"min-w-44 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
|
||||||
|
"data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({ className, ...props }: MenuPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="menu-item"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
|
||||||
|
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator }
|
||||||
|
```
|
||||||
|
IMPORTANT: the exact prop names (`sideOffset`, `align`, `Popup` vs `Popup`+`Positioner` arrangement) MUST be confirmed against the installed `@base-ui/react` types — open `web/node_modules/@base-ui/react/menu/` or check via the editor/types and adjust. Do not guess; if a prop/part errors at typecheck or runtime, fix it to match the real API. No `data-[highlighted]` raw colors — `bg-accent`/`text-accent-foreground` are tokens (OK).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Story** `web/src/components/ui/menu.stories.tsx` (single-quote, no-semicolon). Render a `Menu` with a `MenuTrigger` (a Button via `render` or as child) + `MenuContent` with two `MenuItem`s; a `play` test that opens the menu (click the trigger) and asserts an item is visible:
|
||||||
|
```tsx
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||||
|
import { expect } from 'storybook/test'
|
||||||
|
|
||||||
|
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu'
|
||||||
|
import { Button } from './button'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
component: Menu,
|
||||||
|
tags: ['ai-generated'],
|
||||||
|
} satisfies Meta<typeof Menu>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => (
|
||||||
|
<Menu>
|
||||||
|
<MenuTrigger render={<Button variant="ghost">Open</Button>} />
|
||||||
|
<MenuContent>
|
||||||
|
<MenuItem>First</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem>Second</MenuItem>
|
||||||
|
</MenuContent>
|
||||||
|
</Menu>
|
||||||
|
),
|
||||||
|
play: async ({ canvas, userEvent }) => {
|
||||||
|
await userEvent.click(canvas.getByRole('button', { name: 'Open' }))
|
||||||
|
await expect(await canvas.findByText('First')).toBeInTheDocument()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
If `MenuTrigger render={<Button/>}` isn't the right composition for Base UI Menu, use the pattern that works (e.g. `<MenuTrigger><Button/></MenuTrigger>` or `render` per the alert-dialog usage). The story passing IS the validation.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the story-as-test + typecheck + lint.**
|
||||||
|
`cd web && pnpm vitest run src/components/ui/menu.stories.tsx && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS. If the menu doesn't open / portal isn't found, fix the part tree until the play test passes (this is the validate-by-running step). The portal renders to document.body — `findByText` on the canvas/body should find it; if the addon's `canvas` is scoped, query `within(document.body)` or use the screen — match how other portal-using stories (drawer/combobox/toast) assert.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add web/src/components/ui/menu.tsx web/src/components/ui/menu.stories.tsx
|
||||||
|
git commit -m "feat(web): ui/menu Base UI dropdown wrapper + story (#54)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 3: Breadcrumb infrastructure + mount in header + wire objects-page
|
||||||
|
|
||||||
|
**Files:** `web/src/shell/breadcrumb-context.ts` (new), `breadcrumb-provider.tsx` (new), `use-breadcrumb.ts` (new), `breadcrumb.tsx` (new), `web/src/shell/app-shell.tsx` (modify), `web/src/objects/objects-page.tsx` (modify), `web/src/shell/breadcrumb.test.tsx` (new).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Context** `web/src/shell/breadcrumb-context.ts`:
|
||||||
|
```ts
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export type BreadcrumbItem = { label: string; to?: string };
|
||||||
|
|
||||||
|
type BreadcrumbContextValue = {
|
||||||
|
trail: BreadcrumbItem[];
|
||||||
|
setTrail: (trail: BreadcrumbItem[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BreadcrumbContext = createContext<BreadcrumbContextValue>({
|
||||||
|
trail: [],
|
||||||
|
setTrail: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useBreadcrumbTrail(): BreadcrumbItem[] {
|
||||||
|
return useContext(BreadcrumbContext).trail;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Provider** `web/src/shell/breadcrumb-provider.tsx`:
|
||||||
|
```tsx
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { BreadcrumbContext, type BreadcrumbItem } from "./breadcrumb-context";
|
||||||
|
|
||||||
|
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [trail, setTrail] = useState<BreadcrumbItem[]>([]);
|
||||||
|
return (
|
||||||
|
<BreadcrumbContext.Provider value={{ trail, setTrail }}>
|
||||||
|
{children}
|
||||||
|
</BreadcrumbContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Hook** `web/src/shell/use-breadcrumb.ts`:
|
||||||
|
```ts
|
||||||
|
import { useContext, useEffect } from "react";
|
||||||
|
|
||||||
|
import { BreadcrumbContext, type BreadcrumbItem } from "./breadcrumb-context";
|
||||||
|
|
||||||
|
export function useBreadcrumb(trail: BreadcrumbItem[]): void {
|
||||||
|
const { setTrail } = useContext(BreadcrumbContext);
|
||||||
|
const key = trail.map((i) => `${i.label} | ||||||