From d3c33a6c5db1b5908b38ba21550f8040c241c863 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 20:08:41 +0200 Subject: [PATCH 1/3] feat(domain): derive ToSchema on Visibility/AuthorityKind; add DataType enum (#3 Option A) Co-Authored-By: Claude Sonnet 4.6 --- crates/domain/Cargo.toml | 1 + crates/domain/src/authority.rs | 2 +- crates/domain/src/field_definition.rs | 31 +++++++++++++++++++++++++++ crates/domain/src/lib.rs | 2 +- crates/domain/src/object.rs | 2 +- 5 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index d831a62..39631df 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -9,3 +9,4 @@ uuid.workspace = true serde.workspace = true serde_json.workspace = true time.workspace = true +utoipa.workspace = true diff --git a/crates/domain/src/authority.rs b/crates/domain/src/authority.rs index 7cc550e..aa375e7 100644 --- a/crates/domain/src/authority.rs +++ b/crates/domain/src/authority.rs @@ -7,7 +7,7 @@ use crate::{AuthorityId, LocalizedLabel}; /// NOTE: kept in sync by hand with the /// `CHECK (kind IN ('person', 'organisation', 'place'))` constraint in /// `crates/db/migrations/0002_vocabularies_authorities.sql` — add a variant in both places. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "lowercase")] pub enum AuthorityKind { Person, diff --git a/crates/domain/src/field_definition.rs b/crates/domain/src/field_definition.rs index 45d83ef..66fa47f 100644 --- a/crates/domain/src/field_definition.rs +++ b/crates/domain/src/field_definition.rs @@ -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. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FieldDefinition { @@ -152,4 +169,18 @@ mod tests { ); 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") + ); + } } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index fa3f759..e10dc2c 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -11,7 +11,7 @@ mod vocabulary; pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent}; 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 label::{LocalizedLabel, pick_label}; pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility}; diff --git a/crates/domain/src/object.rs b/crates/domain/src/object.rs index eb3ded6..6754ef9 100644 --- a/crates/domain/src/object.rs +++ b/crates/domain/src/object.rs @@ -4,7 +4,7 @@ use time::{Date, OffsetDateTime}; use crate::ObjectId; /// 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")] pub enum Visibility { /// Work in progress; not shown anywhere public. From 5a72f85989c6e9bdc27e111ea35ea951be905a71 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 20:14:30 +0200 Subject: [PATCH 2/3] feat(api): enum-typed visibility/data_type/kind + open-map fields in OpenAPI (#24 #29) Co-Authored-By: Claude Sonnet 4.6 --- crates/api/src/admin.rs | 1 - crates/api/src/admin_authorities.rs | 1 + crates/api/src/admin_objects.rs | 6 +++-- crates/api/src/openapi.rs | 5 +++- web/src/api/schema.d.ts | 37 +++++++++++++++++++++++------ 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/crates/api/src/admin.rs b/crates/api/src/admin.rs index 07587c7..bf02cbe 100644 --- a/crates/api/src/admin.rs +++ b/crates/api/src/admin.rs @@ -32,7 +32,6 @@ pub(crate) struct UserView { /// Desired visibility for a publish/unpublish request. #[derive(Deserialize, ToSchema)] pub(crate) struct VisibilityRequest { - #[schema(value_type = String)] pub visibility: Visibility, } diff --git a/crates/api/src/admin_authorities.rs b/crates/api/src/admin_authorities.rs index 87d3fba..58fc79d 100644 --- a/crates/api/src/admin_authorities.rs +++ b/crates/api/src/admin_authorities.rs @@ -20,6 +20,7 @@ use crate::{ #[derive(Serialize, ToSchema)] pub(crate) struct AuthorityView { pub id: String, + #[schema(value_type = domain::AuthorityKind)] pub kind: String, pub external_uri: Option, pub labels: Vec, diff --git a/crates/api/src/admin_objects.rs b/crates/api/src/admin_objects.rs index 1b2d1b6..72ea003 100644 --- a/crates/api/src/admin_objects.rs +++ b/crates/api/src/admin_objects.rs @@ -40,9 +40,10 @@ pub(crate) struct AdminObjectView { /// `YYYY-MM-DD` or null. pub recording_date: Option, /// "draft" | "internal" | "public". + #[schema(value_type = domain::Visibility)] pub visibility: String, /// Flexible field values (key -> value). - #[schema(value_type = Object)] + #[schema(value_type = std::collections::HashMap)] pub fields: serde_json::Value, } @@ -162,7 +163,6 @@ pub(crate) struct ObjectCreateRequest { pub recorder: Option, pub recording_date: Option, /// "draft" | "internal" (public is rejected — publish via the visibility endpoint). - #[schema(value_type = String)] pub visibility: Visibility, } @@ -360,8 +360,10 @@ pub(crate) async fn delete_object( pub(crate) struct FieldDefinitionView { pub key: String, /// "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority". + #[schema(value_type = domain::DataType)] pub data_type: String, pub vocabulary_id: Option, + #[schema(value_type = Option)] pub authority_kind: Option, pub required: bool, pub group: Option, diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index e40d9ba..ea75cbc 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -59,7 +59,10 @@ use crate::{ admin_search::SearchHitView, admin_search::SearchResultsView, admin_authorities::AuthorityView, - admin_authorities::NewAuthorityRequest + admin_authorities::NewAuthorityRequest, + domain::Visibility, + domain::AuthorityKind, + domain::DataType )), info(title = "Collection Management System", version = "0.0.0") )] diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index e372e04..e0095dc 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -326,7 +326,9 @@ export interface components { current_location?: string | null; current_owner?: string | null; /** @description Flexible field values (key -> value). */ - fields: Record; + fields: { + [key: string]: unknown; + }; id: string; /** Format: int32 */ number_of_objects: number; @@ -336,12 +338,21 @@ export interface components { /** @description `YYYY-MM-DD` or null. */ recording_date?: string | null; /** @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: { external_uri?: string | null; id: string; - kind: string; + kind: components["schemas"]["AuthorityKind"]; labels: components["schemas"]["LabelView"][]; }; CreatedField: { @@ -354,11 +365,18 @@ export interface components { CreatedObject: { 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. */ FieldDefinitionView: { - authority_kind?: string | null; + authority_kind?: null | components["schemas"]["AuthorityKind"]; /** @description "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority". */ - data_type: string; + data_type: components["schemas"]["DataType"]; group?: string | null; key: string; labels: components["schemas"]["LabelView"][]; @@ -419,7 +437,7 @@ export interface components { recorder?: string | null; recording_date?: string | null; /** @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 @@ -488,9 +506,14 @@ export interface components { id: string; role: string; }; + /** + * @description Publication state of a catalogue record. + * @enum {string} + */ + Visibility: "draft" | "internal" | "public"; /** @description Desired visibility for a publish/unpublish request. */ VisibilityRequest: { - visibility: string; + visibility: components["schemas"]["Visibility"]; }; VocabularyView: { id: string; From 0ee3b970cbb4f0e015e993136dd33a3e5c2b7a5c Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 20:20:13 +0200 Subject: [PATCH 3/3] refactor(web): drop redundant fields/visibility casts now the client is typed (#24 #29) Co-Authored-By: Claude Sonnet 4.6 --- web/src/objects/object-detail.tsx | 2 +- web/src/objects/object-edit-form.tsx | 2 +- web/src/objects/publish-control.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/objects/object-detail.tsx b/web/src/objects/object-detail.tsx index a366d33..3d18fc8 100644 --- a/web/src/objects/object-detail.tsx +++ b/web/src/objects/object-detail.tsx @@ -52,7 +52,7 @@ export function ObjectDetail() { return byLang ?? byEnglish ?? key; }; - const flexible = Object.entries(object.fields as Record); + const flexible = Object.entries(object.fields); return (
diff --git a/web/src/objects/object-edit-form.tsx b/web/src/objects/object-edit-form.tsx index fdf83c7..63bb105 100644 --- a/web/src/objects/object-edit-form.tsx +++ b/web/src/objects/object-edit-form.tsx @@ -34,7 +34,7 @@ export function ObjectEditForm() { recording_date: object.recording_date ?? null, }; - const defaults = { core, fields: object.fields as Record }; + const defaults = { core, fields: object.fields }; const onSubmit = async (values: ObjectFormValues) => { setError(null); diff --git a/web/src/objects/publish-control.tsx b/web/src/objects/publish-control.tsx index ffde5e1..c8ab552 100644 --- a/web/src/objects/publish-control.tsx +++ b/web/src/objects/publish-control.tsx @@ -23,7 +23,7 @@ const STEPS: Visibility[] = ["draft", "internal", "public"]; export function PublishControl({ object }: { object: AdminObjectView }) { const { t } = useTranslation(); - const current = object.visibility as Visibility; + const current = object.visibility; const { forward, back } = adjacentTransitions(current); const setVisibility = useSetVisibility(); const [confirmOpen, setConfirmOpen] = useState(false);