feat(nakui-ui): FieldKind::EntityRef — selector clickable de records existentes
Cierra el principal trade-off documentado del commit anterior:
"Inputs UUID a mano (no dropdown)". Los formularios pueden declarar
un campo entity_ref que apunta a una entity y el runtime renderea
una lista clickable de records existentes; click selecciona, el UUID
queda guardado para el submit.
Schema:
- Nueva variante FieldKind::EntityRef (serializa como "entity_ref").
- FieldSpec.ref_entity: Option<String> nuevo. validate() chequea que
cualquier field con kind=entity_ref tenga ref_entity set.
- Nuevo SchemaError::EntityRefMissingTarget.
Runtime:
- render_entity_ref_selector helper: lista clickable debajo del input,
cada item con etiqueta humana (heuristica: name > label > title >
sku > sku_id > UUID corto) y click handler via cx.listener que
setea el TextInput con el UUID completo. Highlight en accent color
para el seleccionado.
- parse_field_value(EntityRef) devuelve string raw — validacion como
Uuid es responsabilidad de commit_morphism downstream.
- Mensaje "(sin {entity}: crea uno antes...)" cuando lista vacia —
el user sabe que hacer.
Demo actualizado sales_engine: vender_form.stock_id_input y
caja_id_input cambian a kind=entity_ref. Flujo nuevo: click en Stock
listado bajo input, click en Caja, escribir venta_id/cantidad/precio/
timestamp, submit. Sin copiar UUIDs.
Tests: 2 nuevos schema (validate detecta EntityRef sin ref_entity y
acepta con ref_entity) + 4 nuevos runtime (parse, human_label cubre
todos los key fallbacks). 29 tests totales (16 + 8 + 5).
Pendientes: confirmacion de delete, snapshot/compaction del log,
edit delta-only, validacion estricta de params del morphism via
FieldKind del FieldSpec en lugar de infer_param_value.
This commit is contained in:
@@ -176,6 +176,12 @@ pub struct FieldSpec {
|
||||
/// Texto de ayuda mostrado bajo el input.
|
||||
#[serde(default)]
|
||||
pub help: Option<String>,
|
||||
/// Si `kind == EntityRef`, indica qué entity referencia. Sin
|
||||
/// esto, el runtime no sabe qué records ofrecer en el selector
|
||||
/// y la validación `Module::validate` rechaza el manifest.
|
||||
/// Para los demás kinds, este campo se ignora.
|
||||
#[serde(default)]
|
||||
pub ref_entity: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -191,6 +197,11 @@ pub enum FieldKind {
|
||||
Boolean,
|
||||
/// Fecha (formato ISO YYYY-MM-DD; almacenada como string).
|
||||
Date,
|
||||
/// Referencia a otro record. El runtime renderiza un selector
|
||||
/// clickable de records existentes de la entity declarada en
|
||||
/// `FieldSpec.ref_entity`; el value almacenado es el UUID del
|
||||
/// seleccionado, parseable como cualquier text/UUID al submit.
|
||||
EntityRef,
|
||||
}
|
||||
|
||||
/// Acciones disparables por menús, botones o submit de formularios.
|
||||
@@ -279,6 +290,15 @@ pub enum SchemaError {
|
||||
first: PathBuf,
|
||||
second: PathBuf,
|
||||
},
|
||||
#[error(
|
||||
"módulo {id} vista '{view}': field '{field}' tiene kind=entity_ref \
|
||||
pero no declaró ref_entity"
|
||||
)]
|
||||
EntityRefMissingTarget {
|
||||
id: String,
|
||||
view: String,
|
||||
field: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Module {
|
||||
@@ -297,8 +317,10 @@ impl Module {
|
||||
})
|
||||
}
|
||||
|
||||
/// Validación post-parse: cada `MenuItem.view` debe existir en
|
||||
/// `views`. Retorna el primer error encontrado.
|
||||
/// Validación post-parse:
|
||||
/// - Cada `MenuItem.view` debe existir en `views`.
|
||||
/// - Cada `FieldSpec` con `kind=EntityRef` debe declarar
|
||||
/// `ref_entity`.
|
||||
pub fn validate(&self) -> Result<(), SchemaError> {
|
||||
for item in &self.menu {
|
||||
if !self.views.contains_key(&item.view) {
|
||||
@@ -309,6 +331,19 @@ impl Module {
|
||||
});
|
||||
}
|
||||
}
|
||||
for (view_key, view) in &self.views {
|
||||
if let View::Form(form) = view {
|
||||
for f in &form.fields {
|
||||
if f.kind == FieldKind::EntityRef && f.ref_entity.is_none() {
|
||||
return Err(SchemaError::EntityRefMissingTarget {
|
||||
id: self.id.clone(),
|
||||
view: view_key.clone(),
|
||||
field: f.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -375,6 +410,7 @@ mod tests {
|
||||
default: None,
|
||||
required: true,
|
||||
help: None,
|
||||
ref_entity: None,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "email".into(),
|
||||
@@ -383,6 +419,7 @@ mod tests {
|
||||
default: None,
|
||||
required: false,
|
||||
help: Some("Opcional".into()),
|
||||
ref_entity: None,
|
||||
},
|
||||
],
|
||||
}],
|
||||
@@ -435,6 +472,7 @@ mod tests {
|
||||
default: None,
|
||||
required: true,
|
||||
help: None,
|
||||
ref_entity: None,
|
||||
}],
|
||||
on_submit: Action::SeedEntity {
|
||||
entity: "customer".into(),
|
||||
@@ -510,6 +548,68 @@ mod tests {
|
||||
assert_eq!(mods[1].id, "products");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_catches_entity_ref_without_target() {
|
||||
let mut m = sample_module();
|
||||
// Inyectamos un form con un campo EntityRef sin ref_entity.
|
||||
m.views.insert(
|
||||
"broken_form".into(),
|
||||
View::Form(FormView {
|
||||
title: "Roto".into(),
|
||||
entity: "customer".into(),
|
||||
fields: vec![FieldSpec {
|
||||
name: "ref_to_nowhere".into(),
|
||||
label: "Referencia".into(),
|
||||
kind: FieldKind::EntityRef,
|
||||
default: None,
|
||||
required: true,
|
||||
help: None,
|
||||
ref_entity: None,
|
||||
}],
|
||||
on_submit: Action::SeedEntity {
|
||||
entity: "customer".into(),
|
||||
next_view: None,
|
||||
},
|
||||
}),
|
||||
);
|
||||
let err = m.validate().unwrap_err();
|
||||
assert!(
|
||||
matches!(err, SchemaError::EntityRefMissingTarget { .. }),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_ref_with_target_validates_clean() {
|
||||
let mut m = sample_module();
|
||||
m.views.insert(
|
||||
"ok_form".into(),
|
||||
View::Form(FormView {
|
||||
title: "OK".into(),
|
||||
entity: "customer".into(),
|
||||
fields: vec![FieldSpec {
|
||||
name: "supplier".into(),
|
||||
label: "Proveedor".into(),
|
||||
kind: FieldKind::EntityRef,
|
||||
default: None,
|
||||
required: true,
|
||||
help: None,
|
||||
ref_entity: Some("supplier".into()),
|
||||
}],
|
||||
on_submit: Action::SeedEntity {
|
||||
entity: "customer".into(),
|
||||
next_view: None,
|
||||
},
|
||||
}),
|
||||
);
|
||||
m.menu.push(MenuItem {
|
||||
label: "OK".into(),
|
||||
view: "ok_form".into(),
|
||||
icon: None,
|
||||
});
|
||||
m.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_modules_detects_duplicate_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user