Compare commits
206 Commits
ffcfb41c7e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 97c63ac25b | |||
| 62c569741f | |||
| 3ad0e56ecd | |||
| ada5d06dad | |||
| 3a57c0a77c | |||
| 9a896bb5f6 | |||
| 78f5afad35 | |||
| 27205c65ef | |||
| 091a1a651d | |||
| ec11c9dc76 | |||
| 1d19ddfd96 | |||
| 79a6567530 | |||
| fe448034ac | |||
| 67c5da57bf | |||
| 53405d7831 | |||
| e615260422 | |||
| 3b6441688f | |||
| a0b7dcdc2d | |||
| 7f9cf9fe60 | |||
| b83149e0bb | |||
| 80c2aad298 | |||
| b5756e16b5 | |||
| b3f061ced7 | |||
| eec3a261b4 | |||
| 390f6897a8 | |||
| 8b881f369b | |||
| aef5000543 | |||
| 878db9a37b | |||
| 0b44bc0855 | |||
| 79ee402b33 | |||
| 64f35e5a57 | |||
| 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 |
@@ -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 }
|
||||||
@@ -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
|
||||||
@@ -18,12 +20,14 @@ jobs:
|
|||||||
version: 11
|
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
|
||||||
|
|||||||
@@ -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 nextest run --workspace # run all tests — PREFERRED (per-test isolation, live output, hang timeouts)
|
||||||
|
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 +nightly fmt # format — always nightly, not stable
|
||||||
cargo clippy # lint before committing
|
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
+1
@@ -2077,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"
|
||||||
|
|||||||
@@ -49,7 +49,14 @@ BOOTSTRAP_PASSWORD=changeme123 cargo run -p server -- create-user --email you@ex
|
|||||||
```
|
```
|
||||||
Roles are `admin` or `editor`.
|
Roles are `admin` or `editor`.
|
||||||
|
|
||||||
### 5. Run the web frontend
|
### 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
|
The API server serves JSON only; in development the SPA is served by Vite, which proxies
|
||||||
`/api` to `:8080`:
|
`/api` to `:8080`:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -3,18 +3,19 @@
|
|||||||
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::{AuditActor, 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)]
|
||||||
@@ -129,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() })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub(crate) struct UpdateAuthorityRequest {
|
||||||
|
pub external_uri: Option<String>,
|
||||||
|
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> {
|
pub(crate) fn routes() -> Router<AppState> {
|
||||||
Router::new().route(
|
Router::new()
|
||||||
|
.route(
|
||||||
"/api/admin/authorities",
|
"/api/admin/authorities",
|
||||||
get(list_authorities).post(create_authority),
|
get(list_authorities).post(create_authority),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/authorities/{id}",
|
||||||
|
axum::routing::patch(update_authority).delete(delete_authority),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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::{
|
use domain::{
|
||||||
@@ -17,7 +17,7 @@ use domain::{
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::{AppState, admin_vocab::LabelInput, 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)]
|
||||||
@@ -45,6 +45,10 @@ pub(crate) struct AdminObjectView {
|
|||||||
/// Flexible field values (key -> value).
|
/// Flexible field values (key -> value).
|
||||||
#[schema(value_type = std::collections::HashMap<String, serde_json::Value>)]
|
#[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 {
|
||||||
@@ -61,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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,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),
|
||||||
@@ -104,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)?;
|
||||||
|
|
||||||
@@ -510,6 +590,133 @@ pub(crate) async fn create_field_definition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Field-level rejection detail for `set_fields`, so the UI can highlight the field.
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub(crate) struct FieldErrorView {
|
pub(crate) struct FieldErrorView {
|
||||||
@@ -609,4 +816,8 @@ pub(crate) fn routes() -> Router<AppState> {
|
|||||||
"/api/admin/field-definitions",
|
"/api/admin/field-definitions",
|
||||||
get(list_field_definitions).post(create_field_definition),
|
get(list_field_definitions).post(create_field_definition),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/field-definitions/{key}",
|
||||||
|
axum::routing::patch(update_field_definition).delete(delete_field_definition),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub(crate) struct SearchHitView {
|
|||||||
pub brief_description: Option<String>,
|
pub brief_description: Option<String>,
|
||||||
#[schema(value_type = domain::Visibility)]
|
#[schema(value_type = domain::Visibility)]
|
||||||
pub visibility: String,
|
pub visibility: String,
|
||||||
|
pub recording_date: Option<String>,
|
||||||
pub snippet: Option<String>,
|
pub snippet: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,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::{AuditActor, LocalizedLabel, NewTerm, VocabularyId};
|
use domain::{AuditActor, LocalizedLabel, NewTerm, TermId, VocabularyId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
@@ -221,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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,14 +26,22 @@ use crate::{
|
|||||||
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::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,
|
config::ConfigView,
|
||||||
@@ -52,6 +60,7 @@ use crate::{
|
|||||||
admin_objects::CreatedObject,
|
admin_objects::CreatedObject,
|
||||||
admin_objects::FieldDefinitionView,
|
admin_objects::FieldDefinitionView,
|
||||||
admin_objects::NewFieldDefinitionRequest,
|
admin_objects::NewFieldDefinitionRequest,
|
||||||
|
admin_objects::UpdateFieldDefinitionRequest,
|
||||||
admin_objects::CreatedField,
|
admin_objects::CreatedField,
|
||||||
admin_objects::FieldErrorView,
|
admin_objects::FieldErrorView,
|
||||||
admin_vocab::VocabularyView,
|
admin_vocab::VocabularyView,
|
||||||
@@ -60,10 +69,14 @@ use crate::{
|
|||||||
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::Visibility,
|
||||||
domain::AuthorityKind,
|
domain::AuthorityKind,
|
||||||
domain::DataType
|
domain::DataType
|
||||||
|
|||||||
@@ -333,3 +333,653 @@ async fn creating_a_vocabulary_writes_an_audit_entry(pool: PgPool) {
|
|||||||
"expected actor to be a 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -177,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::{AuditActor, 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,
|
||||||
@@ -131,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);
|
||||||
|
|||||||
+138
-2
@@ -1,7 +1,24 @@
|
|||||||
use db::{Db, fields, vocab};
|
use db::{Db, DeleteOutcome, audit, catalog, fields, vocab};
|
||||||
use domain::{AuditActor, 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 {
|
||||||
@@ -171,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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+236
-2
@@ -1,5 +1,8 @@
|
|||||||
use db::{Db, vocab};
|
use db::{Db, audit, catalog, fields, vocab};
|
||||||
use domain::{AuditActor, LocalizedLabel, NewTerm};
|
use domain::{
|
||||||
|
AuditAction, AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, NewTerm, ObjectInput,
|
||||||
|
Visibility,
|
||||||
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
@@ -169,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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -117,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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
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} | ||||||