Compare commits
6 Commits
0d971cda15
...
e9a5a10524
| Author | SHA1 | Date | |
|---|---|---|---|
| e9a5a10524 | |||
| df113bd7ac | |||
| 0ee3b970cb | |||
| 5a72f85989 | |||
| d3c33a6c5d | |||
| 331a6d7f34 |
Generated
+1
@@ -564,6 +564,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"time",
|
"time",
|
||||||
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ pub(crate) struct UserView {
|
|||||||
/// Desired visibility for a publish/unpublish request.
|
/// Desired visibility for a publish/unpublish request.
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub(crate) struct VisibilityRequest {
|
pub(crate) struct VisibilityRequest {
|
||||||
#[schema(value_type = String)]
|
|
||||||
pub visibility: Visibility,
|
pub visibility: Visibility,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use crate::{
|
|||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub(crate) struct AuthorityView {
|
pub(crate) struct AuthorityView {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[schema(value_type = domain::AuthorityKind)]
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub external_uri: Option<String>,
|
pub external_uri: Option<String>,
|
||||||
pub labels: Vec<LabelView>,
|
pub labels: Vec<LabelView>,
|
||||||
|
|||||||
@@ -40,9 +40,10 @@ pub(crate) struct AdminObjectView {
|
|||||||
/// `YYYY-MM-DD` or null.
|
/// `YYYY-MM-DD` or null.
|
||||||
pub recording_date: Option<String>,
|
pub recording_date: Option<String>,
|
||||||
/// "draft" | "internal" | "public".
|
/// "draft" | "internal" | "public".
|
||||||
|
#[schema(value_type = domain::Visibility)]
|
||||||
pub visibility: String,
|
pub visibility: String,
|
||||||
/// Flexible field values (key -> value).
|
/// Flexible field values (key -> value).
|
||||||
#[schema(value_type = Object)]
|
#[schema(value_type = std::collections::HashMap<String, serde_json::Value>)]
|
||||||
pub fields: serde_json::Value,
|
pub fields: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +163,6 @@ pub(crate) struct ObjectCreateRequest {
|
|||||||
pub recorder: Option<String>,
|
pub recorder: Option<String>,
|
||||||
pub recording_date: Option<String>,
|
pub recording_date: Option<String>,
|
||||||
/// "draft" | "internal" (public is rejected — publish via the visibility endpoint).
|
/// "draft" | "internal" (public is rejected — publish via the visibility endpoint).
|
||||||
#[schema(value_type = String)]
|
|
||||||
pub visibility: Visibility,
|
pub visibility: Visibility,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,8 +360,10 @@ pub(crate) async fn delete_object(
|
|||||||
pub(crate) struct FieldDefinitionView {
|
pub(crate) struct FieldDefinitionView {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
/// "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority".
|
/// "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority".
|
||||||
|
#[schema(value_type = domain::DataType)]
|
||||||
pub data_type: String,
|
pub data_type: String,
|
||||||
pub vocabulary_id: Option<String>,
|
pub vocabulary_id: Option<String>,
|
||||||
|
#[schema(value_type = Option<domain::AuthorityKind>)]
|
||||||
pub authority_kind: Option<String>,
|
pub authority_kind: Option<String>,
|
||||||
pub required: bool,
|
pub required: bool,
|
||||||
pub group: Option<String>,
|
pub group: Option<String>,
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ use crate::{
|
|||||||
admin_search::SearchHitView,
|
admin_search::SearchHitView,
|
||||||
admin_search::SearchResultsView,
|
admin_search::SearchResultsView,
|
||||||
admin_authorities::AuthorityView,
|
admin_authorities::AuthorityView,
|
||||||
admin_authorities::NewAuthorityRequest
|
admin_authorities::NewAuthorityRequest,
|
||||||
|
domain::Visibility,
|
||||||
|
domain::AuthorityKind,
|
||||||
|
domain::DataType
|
||||||
)),
|
)),
|
||||||
info(title = "Collection Management System", version = "0.0.0")
|
info(title = "Collection Management System", version = "0.0.0")
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ uuid.workspace = true
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
time.workspace = true
|
time.workspace = true
|
||||||
|
utoipa.workspace = true
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::{AuthorityId, LocalizedLabel};
|
|||||||
/// NOTE: kept in sync by hand with the
|
/// NOTE: kept in sync by hand with the
|
||||||
/// `CHECK (kind IN ('person', 'organisation', 'place'))` constraint in
|
/// `CHECK (kind IN ('person', 'organisation', 'place'))` constraint in
|
||||||
/// `crates/db/migrations/0002_vocabularies_authorities.sql` — add a variant in both places.
|
/// `crates/db/migrations/0002_vocabularies_authorities.sql` — add a variant in both places.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum AuthorityKind {
|
pub enum AuthorityKind {
|
||||||
Person,
|
Person,
|
||||||
|
|||||||
@@ -74,6 +74,23 @@ impl FieldType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The stored `data_type` discriminant of a field definition — mirrors the strings from
|
||||||
|
/// [`FieldType::kind_str`]. Exists so the OpenAPI schema can describe `data_type` as a
|
||||||
|
/// closed string enum (consumed by the typed web client). Keep in sync with `kind_str`.
|
||||||
|
#[derive(
|
||||||
|
Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
|
||||||
|
)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DataType {
|
||||||
|
Text,
|
||||||
|
LocalizedText,
|
||||||
|
Integer,
|
||||||
|
Date,
|
||||||
|
Boolean,
|
||||||
|
Term,
|
||||||
|
Authority,
|
||||||
|
}
|
||||||
|
|
||||||
/// A registered flexible field, with its multilingual display labels.
|
/// A registered flexible field, with its multilingual display labels.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct FieldDefinition {
|
pub struct FieldDefinition {
|
||||||
@@ -152,4 +169,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(FieldType::from_parts("authority", Some(v), None), None);
|
assert_eq!(FieldType::from_parts("authority", Some(v), None), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_type_serde_matches_kind_str() {
|
||||||
|
use serde_json::json;
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(DataType::LocalizedText).unwrap(),
|
||||||
|
json!("localized_text")
|
||||||
|
);
|
||||||
|
assert_eq!(serde_json::to_value(DataType::Text).unwrap(), json!("text"));
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(DataType::Authority).unwrap(),
|
||||||
|
json!("authority")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ mod vocabulary;
|
|||||||
|
|
||||||
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
|
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
|
||||||
pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority};
|
pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority};
|
||||||
pub use field_definition::{FieldDefinition, FieldType, NewFieldDefinition};
|
pub use field_definition::{DataType, FieldDefinition, FieldType, NewFieldDefinition};
|
||||||
pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, UserId, VocabularyId};
|
pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, UserId, VocabularyId};
|
||||||
pub use label::{LocalizedLabel, pick_label};
|
pub use label::{LocalizedLabel, pick_label};
|
||||||
pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility};
|
pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use time::{Date, OffsetDateTime};
|
|||||||
use crate::ObjectId;
|
use crate::ObjectId;
|
||||||
|
|
||||||
/// Publication state of a catalogue record.
|
/// Publication state of a catalogue record.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Visibility {
|
pub enum Visibility {
|
||||||
/// Work in progress; not shown anywhere public.
|
/// Work in progress; not shown anywhere public.
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# Tier 3 — Typed-Client Quality Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||||
|
|
||||||
|
**Goal:** Tighten the generated OpenAPI/TypeScript contract so the frontend drops its `as`-casts — type the free-form `fields` map as an open map (#24) and the enum-valued fields (`visibility`, `data_type`, authority `kind`) as string enums (#29). Architecture decision #3 = **Option A** (allow `utoipa::ToSchema` in `domain`).
|
||||||
|
|
||||||
|
**Architecture:** `domain`'s already-serde enums gain `ToSchema`; a new `DataType` enum is added to `domain` for the `data_type` discriminant. The `api` View DTOs reference these via `#[schema(value_type = …)]` (fields stay `String`/`Value` at runtime; only the *schema description* changes). Regenerate `schema.d.ts`; remove the now-redundant frontend casts.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (utoipa 5, sqlx), React + TS, openapi-typescript.
|
||||||
|
|
||||||
|
**Conventions:** nightly fmt; clippy; no `any`/`eslint-disable`/`@ts-ignore`; no codename. Test infra: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev`, `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `domain` — `ToSchema` on enums + new `DataType`
|
||||||
|
|
||||||
|
**Files:** `crates/domain/Cargo.toml`, `crates/domain/src/object.rs`, `crates/domain/src/authority.rs`, `crates/domain/src/field_definition.rs`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the utoipa dep.** In `crates/domain/Cargo.toml` `[dependencies]`, add:
|
||||||
|
```toml
|
||||||
|
utoipa.workspace = true
|
||||||
|
```
|
||||||
|
(The workspace already defines `utoipa = { version = "5", features = ["uuid"] }`.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Derive `ToSchema` on `Visibility`** (`crates/domain/src/object.rs:7-9`). Add `utoipa::ToSchema` to the derive list (keep everything else):
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Visibility {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Derive `ToSchema` on `AuthorityKind`** (`crates/domain/src/authority.rs:10-12`):
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum AuthorityKind {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add a `DataType` enum** to `crates/domain/src/field_definition.rs` (it describes the `data_type` discriminant string that `FieldType::kind_str()` produces). NOTE: **`snake_case`**, so `LocalizedText` → `"localized_text"` (matching `kind_str`):
|
||||||
|
```rust
|
||||||
|
/// The stored `data_type` discriminant of a field definition. This mirrors the strings
|
||||||
|
/// produced by [`FieldType::kind_str`]; it exists so the OpenAPI schema can describe
|
||||||
|
/// `data_type` as a closed string enum (consumed by the typed web client). Kept in sync
|
||||||
|
/// by hand with `FieldType::kind_str`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DataType {
|
||||||
|
Text,
|
||||||
|
LocalizedText,
|
||||||
|
Integer,
|
||||||
|
Date,
|
||||||
|
Boolean,
|
||||||
|
Term,
|
||||||
|
Authority,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(If `serde::{Serialize, Deserialize}` are already imported at the top of the file, use the bare derive names; otherwise the fully-qualified `serde::Serialize` forms above are fine.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify** — `cargo +nightly fmt`, `cargo build -p domain`, `cargo clippy -p domain --all-targets`. The existing `field_type_round_trips` etc. tests still pass: `cargo test -p domain`. Add a tiny test asserting `DataType` serializes correctly (it must match `kind_str`):
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn data_type_serde_matches_kind_str() {
|
||||||
|
use serde_json::json;
|
||||||
|
assert_eq!(serde_json::to_value(DataType::LocalizedText).unwrap(), json!("localized_text"));
|
||||||
|
assert_eq!(serde_json::to_value(DataType::Text).unwrap(), json!("text"));
|
||||||
|
assert_eq!(serde_json::to_value(DataType::Authority).unwrap(), json!("authority"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(place it in the existing `#[cfg(test)] mod tests` in `field_definition.rs`).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add crates/domain
|
||||||
|
git commit -m "feat(domain): derive ToSchema on Visibility/AuthorityKind; add DataType enum (#3 Option A)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `api` — enum + open-map schema annotations + regenerate client
|
||||||
|
|
||||||
|
**Files:** `crates/api/src/admin_objects.rs`, `crates/api/src/admin_authorities.rs`, `crates/api/src/admin.rs`, `crates/api/src/openapi.rs`; regenerate `web/src/api/schema.d.ts`.
|
||||||
|
|
||||||
|
> The View fields keep their runtime types (`String` / `serde_json::Value`); only the `#[schema(value_type = …)]` annotation changes what the OpenAPI document says. No handler/construction logic changes.
|
||||||
|
|
||||||
|
- [ ] **Step 1: #24 — open-map `fields`.** In `crates/api/src/admin_objects.rs:45`, change `AdminObjectView.fields`:
|
||||||
|
```rust
|
||||||
|
#[schema(value_type = std::collections::HashMap<String, serde_json::Value>)]
|
||||||
|
pub fields: serde_json::Value,
|
||||||
|
```
|
||||||
|
(This is the only `value_type = Object` site — confirmed by `grep -rn "value_type = Object" crates/api/src`.) This makes utoipa emit `additionalProperties`, which `openapi-typescript` renders as `{ [key: string]: unknown }` instead of `Record<string, never>`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: #29 — `visibility` enums.**
|
||||||
|
- `AdminObjectView.visibility` (`admin_objects.rs:43`, currently `pub visibility: String`): add above it `#[schema(value_type = domain::Visibility)]`.
|
||||||
|
- `ObjectCreateRequest.visibility` (`admin_objects.rs:165-166`): **remove** the `#[schema(value_type = String)]` line so the field (`pub visibility: Visibility`) emits the enum.
|
||||||
|
- `VisibilityRequest.visibility` (`crates/api/src/admin.rs`, field is `pub visibility: Visibility`): if it has a `#[schema(value_type = String)]` override, **remove** it so it emits the enum. (Check — it may or may not have one.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: #29 — `data_type` + `authority_kind` enums.** In `crates/api/src/admin_objects.rs`, `FieldDefinitionView` (~lines 360-366):
|
||||||
|
- `data_type` (line 363): add `#[schema(value_type = domain::DataType)]`.
|
||||||
|
- `authority_kind` (line 365): add `#[schema(value_type = Option<domain::AuthorityKind>)]`.
|
||||||
|
- The `NewFieldDefinitionRequest` (~lines 374-377) `data_type`/`authority_kind` are request inputs parsed as free strings by the handler — **leave these as `String`** (typing them would force handler conversion; out of scope, and the create form posts plain strings).
|
||||||
|
|
||||||
|
- [ ] **Step 4: #29 — authority `kind`.** In `crates/api/src/admin_authorities.rs`, `AuthorityView.kind` (line 23, `pub kind: String`): add `#[schema(value_type = domain::AuthorityKind)]`. Leave `NewAuthorityRequest.kind` (line 31) as `String` (request input parsed via `from_db`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Register the domain enums as OpenAPI components.** In `crates/api/src/openapi.rs` `components(schemas(...))`, add:
|
||||||
|
```rust
|
||||||
|
domain::Visibility,
|
||||||
|
domain::AuthorityKind,
|
||||||
|
domain::DataType,
|
||||||
|
```
|
||||||
|
(utoipa generates `$ref`s to these from the `value_type` annotations; they must be registered. The `api` crate already depends on `domain`.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build + backend tests.**
|
||||||
|
```bash
|
||||||
|
cargo +nightly fmt
|
||||||
|
cargo clippy -p api --all-targets
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api
|
||||||
|
```
|
||||||
|
All green (serialized values are unchanged — `visibility` still serializes "draft" etc., `data_type` still "text"/"localized_text").
|
||||||
|
|
||||||
|
- [ ] **Step 7: Regenerate the typed client.**
|
||||||
|
```bash
|
||||||
|
cargo build -p server
|
||||||
|
lsof -ti :8080 | xargs kill 2>/dev/null
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey ./target/debug/server &
|
||||||
|
SERVER_PID=$!
|
||||||
|
sleep 2
|
||||||
|
( cd web && pnpm gen:api )
|
||||||
|
kill "$SERVER_PID"
|
||||||
|
```
|
||||||
|
Verify the generated types:
|
||||||
|
```bash
|
||||||
|
grep -n "Visibility:\|AuthorityKind:\|DataType:" web/src/api/schema.d.ts
|
||||||
|
grep -n "additionalProperties\|\[key: string\]: unknown" web/src/api/schema.d.ts | head
|
||||||
|
```
|
||||||
|
Expect `Visibility: "draft" | "internal" | "public"`, `AuthorityKind: "person" | "organisation" | "place"`, `DataType: "text" | "localized_text" | ...`, and `AdminObjectView.fields` as `{ [key: string]: unknown }`. Then `cd web && pnpm typecheck` — it may now report errors at the cast sites (expected; Task 3 fixes them) OR pass (casts on a now-compatible type are just redundant). Either way, do NOT edit web source in this task beyond the regenerated `schema.d.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add crates/api web/src/api/schema.d.ts
|
||||||
|
git commit -m "feat(api): enum-typed visibility/data_type/kind + open-map fields in OpenAPI (#24 #29)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Frontend — drop the now-redundant casts
|
||||||
|
|
||||||
|
**Files:** `web/src/objects/object-detail.tsx`, `web/src/objects/object-form.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/objects/publish-control.tsx` (+ check `visibility-badge.tsx`, `field-input.tsx`). Plus any local `Visibility` type alias.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove the `fields` casts (#24).** `fields` is now `{ [key: string]: unknown }`:
|
||||||
|
- `object-detail.tsx:55`: `Object.entries(object.fields as Record<string, unknown>)` → `Object.entries(object.fields)`.
|
||||||
|
- `object-form.tsx:181`: `Object.entries(value as Record<string, unknown>)` → `Object.entries(value)` (only if `value` is the typed `fields`; if `value` is a generic RHF value, the cast may still be needed — verify the type and remove only if redundant).
|
||||||
|
- `object-edit-form.tsx:37`: `fields: object.fields as Record<string, unknown>` → `fields: object.fields` (if the target type accepts the open map; otherwise leave).
|
||||||
|
Remove a cast only when the typecheck confirms it's now redundant. Keep the code `any`-free.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove the `visibility` cast (#29).** `publish-control.tsx:26`: `const current = object.visibility as Visibility;` → `const current = object.visibility;` (it's now the `"draft" | "internal" | "public"` union). If a local `type Visibility = ...` alias exists and is now identical to the schema union, prefer referencing `components["schemas"]["Visibility"]` or keep the alias if it's used as a shared name — but drop the cast. Check `visibility-badge.tsx`: if its prop is `visibility: string`, you may tighten it to the union or leave it (a union is assignable to `string`); do NOT introduce errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `data_type` (#29).** `field-input.tsx` switches on `data_type` — now a union. No cast was present; confirm the switch still typechecks (a union improves exhaustiveness). If there's a `data_type as ...` cast anywhere, remove it.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify.**
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
|
||||||
|
```
|
||||||
|
All green; no `any`/`@ts-ignore` introduced; bundle ≤150 KB. Grep to confirm the casts are gone:
|
||||||
|
```bash
|
||||||
|
grep -rn "as Record<string, unknown>\|as Visibility" web/src/objects | grep -v ".test."
|
||||||
|
```
|
||||||
|
(Test-file `as Record<string, unknown>` defaults may remain — they're test scaffolding, not contract casts; leaving them is fine.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add web
|
||||||
|
git commit -m "refactor(web): drop redundant fields/visibility casts now the client is typed (#24 #29)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1:** `cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (report bundle gz).
|
||||||
|
- [ ] **Step 2:**
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p domain
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo +nightly fmt --check
|
||||||
|
```
|
||||||
|
- [ ] **Step 3:** i18n parity check (unchanged keys, but run it); `git grep -in 'biggus\|dickus' -- crates web/src` → none.
|
||||||
|
- [ ] **Step 4:** Confirm acceptance: OpenAPI `fields` has `additionalProperties`; `visibility`/`data_type`/`kind` are string enums in `schema.d.ts`; the `as Record<string, unknown>`/`as Visibility` contract casts are gone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
- **Spec coverage:** #3 decided (Option A, documented + closed) → this plan's architecture; #24 (open-map fields) → T2 Step 1 + T3 Step 1; #29 (visibility/data_type/kind enums) → T1 + T2 Steps 2-5 + T3 Steps 2-3. ✓
|
||||||
|
- **Placeholder scan:** none — exact files/lines/annotations given; the "remove cast only if typecheck confirms redundant" notes are correct verification guards (the generated types determine redundancy).
|
||||||
|
- **Type consistency:** `DataType` uses `snake_case` to match `FieldType::kind_str` (`localized_text`); `value_type = domain::X` references match the enums registered in `openapi.rs` components; runtime serialization is unchanged (backend tests prove it), so only the schema/TS types tighten.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Request-side enums (`NewFieldDefinitionRequest.data_type`/`authority_kind`, `NewAuthorityRequest.kind`) intentionally stay `String` — the handlers parse/validate them; typing them is a separate, larger change (would need handler conversion) and isn't required by #24/#29.
|
||||||
Vendored
+30
-7
@@ -326,7 +326,9 @@ export interface components {
|
|||||||
current_location?: string | null;
|
current_location?: string | null;
|
||||||
current_owner?: string | null;
|
current_owner?: string | null;
|
||||||
/** @description Flexible field values (key -> value). */
|
/** @description Flexible field values (key -> value). */
|
||||||
fields: Record<string, never>;
|
fields: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
id: string;
|
id: string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
number_of_objects: number;
|
number_of_objects: number;
|
||||||
@@ -336,12 +338,21 @@ export interface components {
|
|||||||
/** @description `YYYY-MM-DD` or null. */
|
/** @description `YYYY-MM-DD` or null. */
|
||||||
recording_date?: string | null;
|
recording_date?: string | null;
|
||||||
/** @description "draft" | "internal" | "public". */
|
/** @description "draft" | "internal" | "public". */
|
||||||
visibility: string;
|
visibility: components["schemas"]["Visibility"];
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* @description The kind of authority record.
|
||||||
|
*
|
||||||
|
* NOTE: kept in sync by hand with the
|
||||||
|
* `CHECK (kind IN ('person', 'organisation', 'place'))` constraint in
|
||||||
|
* `crates/db/migrations/0002_vocabularies_authorities.sql` — add a variant in both places.
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
AuthorityKind: "person" | "organisation" | "place";
|
||||||
AuthorityView: {
|
AuthorityView: {
|
||||||
external_uri?: string | null;
|
external_uri?: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
kind: string;
|
kind: components["schemas"]["AuthorityKind"];
|
||||||
labels: components["schemas"]["LabelView"][];
|
labels: components["schemas"]["LabelView"][];
|
||||||
};
|
};
|
||||||
CreatedField: {
|
CreatedField: {
|
||||||
@@ -354,11 +365,18 @@ export interface components {
|
|||||||
CreatedObject: {
|
CreatedObject: {
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* @description The stored `data_type` discriminant of a field definition — mirrors the strings from
|
||||||
|
* [`FieldType::kind_str`]. Exists so the OpenAPI schema can describe `data_type` as a
|
||||||
|
* closed string enum (consumed by the typed web client). Keep in sync with `kind_str`.
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
DataType: "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority";
|
||||||
/** @description Field-definition descriptor for the UI to render forms. */
|
/** @description Field-definition descriptor for the UI to render forms. */
|
||||||
FieldDefinitionView: {
|
FieldDefinitionView: {
|
||||||
authority_kind?: string | null;
|
authority_kind?: null | components["schemas"]["AuthorityKind"];
|
||||||
/** @description "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority". */
|
/** @description "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority". */
|
||||||
data_type: string;
|
data_type: components["schemas"]["DataType"];
|
||||||
group?: string | null;
|
group?: string | null;
|
||||||
key: string;
|
key: string;
|
||||||
labels: components["schemas"]["LabelView"][];
|
labels: components["schemas"]["LabelView"][];
|
||||||
@@ -419,7 +437,7 @@ export interface components {
|
|||||||
recorder?: string | null;
|
recorder?: string | null;
|
||||||
recording_date?: string | null;
|
recording_date?: string | null;
|
||||||
/** @description "draft" | "internal" (public is rejected — publish via the visibility endpoint). */
|
/** @description "draft" | "internal" (public is rejected — publish via the visibility endpoint). */
|
||||||
visibility: string;
|
visibility: components["schemas"]["Visibility"];
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* @description Inventory-minimum fields for update. Visibility is intentionally absent — it changes
|
* @description Inventory-minimum fields for update. Visibility is intentionally absent — it changes
|
||||||
@@ -488,9 +506,14 @@ export interface components {
|
|||||||
id: string;
|
id: string;
|
||||||
role: string;
|
role: string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* @description Publication state of a catalogue record.
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
Visibility: "draft" | "internal" | "public";
|
||||||
/** @description Desired visibility for a publish/unpublish request. */
|
/** @description Desired visibility for a publish/unpublish request. */
|
||||||
VisibilityRequest: {
|
VisibilityRequest: {
|
||||||
visibility: string;
|
visibility: components["schemas"]["Visibility"];
|
||||||
};
|
};
|
||||||
VocabularyView: {
|
VocabularyView: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function ObjectDetail() {
|
|||||||
return byLang ?? byEnglish ?? key;
|
return byLang ?? byEnglish ?? key;
|
||||||
};
|
};
|
||||||
|
|
||||||
const flexible = Object.entries(object.fields as Record<string, unknown>);
|
const flexible = Object.entries(object.fields);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto p-4">
|
<div className="overflow-auto p-4">
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function ObjectEditForm() {
|
|||||||
recording_date: object.recording_date ?? null,
|
recording_date: object.recording_date ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaults = { core, fields: object.fields as Record<string, unknown> };
|
const defaults = { core, fields: object.fields };
|
||||||
|
|
||||||
const onSubmit = async (values: ObjectFormValues) => {
|
const onSubmit = async (values: ObjectFormValues) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const STEPS: Visibility[] = ["draft", "internal", "public"];
|
|||||||
|
|
||||||
export function PublishControl({ object }: { object: AdminObjectView }) {
|
export function PublishControl({ object }: { object: AdminObjectView }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const current = object.visibility as Visibility;
|
const current = object.visibility;
|
||||||
const { forward, back } = adjacentTransitions(current);
|
const { forward, back } = adjacentTransitions(current);
|
||||||
const setVisibility = useSetVisibility();
|
const setVisibility = useSetVisibility();
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user