Decision #3 = Option A (utoipa::ToSchema allowed in domain, no I/O deps). domain: ToSchema on Visibility/AuthorityKind + new DataType enum. api: open-map fields (#24); enum value_types for visibility/data_type/kind (#29); domain enums registered in OpenAPI; client regenerated. Frontend: dropped the now-redundant fields/visibility casts. Wire format unchanged; schema diff additive. domain 26 + api 41 + web 78 tests; bundle 145.5 KB gz. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
pub labels: Vec<LabelView>,
|
||||
|
||||
@@ -40,9 +40,10 @@ pub(crate) struct AdminObjectView {
|
||||
/// `YYYY-MM-DD` or null.
|
||||
pub recording_date: Option<String>,
|
||||
/// "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<String, serde_json::Value>)]
|
||||
pub fields: serde_json::Value,
|
||||
}
|
||||
|
||||
@@ -162,7 +163,6 @@ pub(crate) struct ObjectCreateRequest {
|
||||
pub recorder: Option<String>,
|
||||
pub recording_date: Option<String>,
|
||||
/// "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<String>,
|
||||
#[schema(value_type = Option<domain::AuthorityKind>)]
|
||||
pub authority_kind: Option<String>,
|
||||
pub required: bool,
|
||||
pub group: Option<String>,
|
||||
|
||||
@@ -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")
|
||||
)]
|
||||
|
||||
@@ -9,3 +9,4 @@ uuid.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
time.workspace = true
|
||||
utoipa.workspace = true
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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.
|
||||
|
||||
Vendored
+30
-7
@@ -326,7 +326,9 @@ export interface components {
|
||||
current_location?: string | null;
|
||||
current_owner?: string | null;
|
||||
/** @description Flexible field values (key -> value). */
|
||||
fields: Record<string, never>;
|
||||
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;
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ObjectDetail() {
|
||||
return byLang ?? byEnglish ?? key;
|
||||
};
|
||||
|
||||
const flexible = Object.entries(object.fields as Record<string, unknown>);
|
||||
const flexible = Object.entries(object.fields);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto p-4">
|
||||
|
||||
@@ -34,7 +34,7 @@ export function ObjectEditForm() {
|
||||
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) => {
|
||||
setError(null);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user