feat(nakui): Fase 1 del ERP — FieldKind Select + AutoId, seed inyecta id
Primera fase del plan maestro. La metainterfaz gana dos tipos de campo: Select (chips de un conjunto cerrado, con options validadas) y AutoId (UUID autogenerado read-only). NakuiBackend::seed inyecta el id de la entity = clave del store. El módulo CRM los adopta: etapa/canal son selects, los ids de idempotencia se autogeneran, el form de cliente ya no pide id. Ningún formulario pide un UUID a mano. Tests en meta-schema, meta-runtime y nakui-ui verdes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -137,7 +137,10 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
|
||||
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
||||
items
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -180,7 +183,10 @@ mod tests {
|
||||
fn update_with_set_changes_field() {
|
||||
let mut b = MockBackend::new();
|
||||
let id = b
|
||||
.seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))]))
|
||||
.seed(
|
||||
"Customer",
|
||||
map_of(&[("name", json!("Acme")), ("notes", json!("x"))]),
|
||||
)
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
@@ -205,7 +211,10 @@ mod tests {
|
||||
fn update_with_clear_removes_key() {
|
||||
let mut b = MockBackend::new();
|
||||
let id = b
|
||||
.seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))]))
|
||||
.seed(
|
||||
"Customer",
|
||||
map_of(&[("name", json!("Acme")), ("notes", json!("x"))]),
|
||||
)
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
|
||||
@@ -50,7 +50,10 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
fn map(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
|
||||
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
||||
items
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,15 +15,20 @@ use nahual_meta_schema::{FieldKind, FieldSpec};
|
||||
/// - `Number` → i64 si parsea, sino f64.
|
||||
pub fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
|
||||
match kind {
|
||||
FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
|
||||
// Select y AutoId guardan un string: el valor de la opción
|
||||
// elegida y el UUID autogenerado, respectivamente.
|
||||
FieldKind::Text
|
||||
| FieldKind::Multiline
|
||||
| FieldKind::Date
|
||||
| FieldKind::Select
|
||||
| FieldKind::AutoId => Ok(json!(raw)),
|
||||
// EntityRef se almacena como string del UUID seleccionado.
|
||||
// El selector clickable garantiza UUIDs válidos en happy
|
||||
// path; este check protege paste manual o garbage tipeado.
|
||||
FieldKind::EntityRef => {
|
||||
let trimmed = raw.trim();
|
||||
Uuid::parse_str(trimmed).map_err(|_| {
|
||||
format!("'{raw}' no es UUID válido (usá el selector de records)")
|
||||
})?;
|
||||
Uuid::parse_str(trimmed)
|
||||
.map_err(|_| format!("'{raw}' no es UUID válido (usá el selector de records)"))?;
|
||||
Ok(json!(trimmed))
|
||||
}
|
||||
FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
|
||||
@@ -62,7 +67,11 @@ pub fn resolve_param_value(
|
||||
return Ok(infer_param_value(raw));
|
||||
};
|
||||
|
||||
let label = if s.label.is_empty() { field_name } else { &s.label };
|
||||
let label = if s.label.is_empty() {
|
||||
field_name
|
||||
} else {
|
||||
&s.label
|
||||
};
|
||||
|
||||
if s.required && raw.trim().is_empty() {
|
||||
return Err(format!("param '{label}' es obligatorio y está vacío"));
|
||||
@@ -112,6 +121,7 @@ mod tests {
|
||||
required,
|
||||
help: None,
|
||||
ref_entity: None,
|
||||
options: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +129,7 @@ mod tests {
|
||||
fn infer_handles_basic_types() {
|
||||
assert_eq!(infer_param_value(""), Value::Null);
|
||||
assert_eq!(infer_param_value("42"), json!(42));
|
||||
assert_eq!(infer_param_value("3.14"), json!(3.14));
|
||||
assert_eq!(infer_param_value("2.5"), json!(2.5));
|
||||
assert_eq!(infer_param_value("true"), json!(true));
|
||||
assert_eq!(infer_param_value("false"), json!(false));
|
||||
assert_eq!(infer_param_value("hola"), json!("hola"));
|
||||
@@ -132,11 +142,29 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_number_i64_or_f64() {
|
||||
assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42));
|
||||
fn parse_select_and_auto_id_passthrough() {
|
||||
// Select guarda el valor de la opción elegida.
|
||||
assert_eq!(
|
||||
parse_field_value(FieldKind::Number, "3.14").unwrap(),
|
||||
json!(3.14)
|
||||
parse_field_value(FieldKind::Select, "ganada").unwrap(),
|
||||
json!("ganada")
|
||||
);
|
||||
// AutoId guarda el UUID autogenerado tal cual.
|
||||
let id = Uuid::new_v4().to_string();
|
||||
assert_eq!(
|
||||
parse_field_value(FieldKind::AutoId, &id).unwrap(),
|
||||
json!(id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_number_i64_or_f64() {
|
||||
assert_eq!(
|
||||
parse_field_value(FieldKind::Number, "42").unwrap(),
|
||||
json!(42)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_field_value(FieldKind::Number, "2.5").unwrap(),
|
||||
json!(2.5)
|
||||
);
|
||||
assert!(parse_field_value(FieldKind::Number, "abc").is_err());
|
||||
}
|
||||
@@ -144,7 +172,10 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_boolean_recognizes_variants() {
|
||||
for s in ["true", "yes", "1", "on", "y"] {
|
||||
assert_eq!(parse_field_value(FieldKind::Boolean, s).unwrap(), json!(true));
|
||||
assert_eq!(
|
||||
parse_field_value(FieldKind::Boolean, s).unwrap(),
|
||||
json!(true)
|
||||
);
|
||||
}
|
||||
for s in ["false", "no", "0", "off", "n", ""] {
|
||||
assert_eq!(
|
||||
|
||||
@@ -204,30 +204,24 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
|
||||
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
||||
items
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_records_populates_state() {
|
||||
let id = Uuid::new_v4();
|
||||
let b = MockBackend::with_records([(
|
||||
"Customer".into(),
|
||||
id,
|
||||
json!({"name": "Acme"}),
|
||||
)]);
|
||||
let b = MockBackend::with_records([("Customer".into(), id, json!({"name": "Acme"}))]);
|
||||
assert_eq!(b.total_records(), 1);
|
||||
assert_eq!(
|
||||
b.load_record("Customer", id),
|
||||
Some(json!({"name": "Acme"}))
|
||||
);
|
||||
assert_eq!(b.load_record("Customer", id), Some(json!({"name": "Acme"})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_then_load_round_trip_via_trait() {
|
||||
let mut b = MockBackend::new();
|
||||
let out = b
|
||||
.seed("X", map_of(&[("k", json!(1))]))
|
||||
.unwrap();
|
||||
let out = b.seed("X", map_of(&[("k", json!(1))])).unwrap();
|
||||
let id = out.id.unwrap();
|
||||
assert_eq!(out.changed, 1);
|
||||
assert_eq!(b.load_record("X", id), Some(json!({"k": 1})));
|
||||
@@ -236,25 +230,15 @@ mod tests {
|
||||
#[test]
|
||||
fn update_no_op_returns_no_change() {
|
||||
let id = Uuid::new_v4();
|
||||
let mut b = MockBackend::with_records([(
|
||||
"X".into(),
|
||||
id,
|
||||
json!({"k": 1}),
|
||||
)]);
|
||||
let out = b
|
||||
.update("X", id, serde_json::Map::new(), vec![])
|
||||
.unwrap();
|
||||
let mut b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
|
||||
let out = b.update("X", id, serde_json::Map::new(), vec![]).unwrap();
|
||||
assert_eq!(out, WriteOutcome::no_change(id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_set_and_clear_aplica_ambos() {
|
||||
let id = Uuid::new_v4();
|
||||
let mut b = MockBackend::with_records([(
|
||||
"X".into(),
|
||||
id,
|
||||
json!({"a": 1, "b": 2}),
|
||||
)]);
|
||||
let mut b = MockBackend::with_records([("X".into(), id, json!({"a": 1, "b": 2}))]);
|
||||
let out = b
|
||||
.update("X", id, map_of(&[("a", json!(10))]), vec!["b".into()])
|
||||
.unwrap();
|
||||
@@ -267,11 +251,7 @@ mod tests {
|
||||
#[test]
|
||||
fn delete_then_load_returns_none() {
|
||||
let id = Uuid::new_v4();
|
||||
let mut b = MockBackend::with_records([(
|
||||
"X".into(),
|
||||
id,
|
||||
json!({"k": 1}),
|
||||
)]);
|
||||
let mut b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
|
||||
b.delete("X", id).unwrap();
|
||||
assert!(b.load_record("X", id).is_none());
|
||||
}
|
||||
@@ -287,17 +267,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn with_morphism_lets_caller_simulate_logic() {
|
||||
let mut b = MockBackend::new().with_morphism(
|
||||
"double_qty",
|
||||
|inputs, params| {
|
||||
assert!(inputs.is_empty());
|
||||
let qty = params.get("qty").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
if qty <= 0 {
|
||||
return Err("qty must be positive".into());
|
||||
}
|
||||
Ok(qty as usize)
|
||||
},
|
||||
);
|
||||
let mut b = MockBackend::new().with_morphism("double_qty", |inputs, params| {
|
||||
assert!(inputs.is_empty());
|
||||
let qty = params.get("qty").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
if qty <= 0 {
|
||||
return Err("qty must be positive".into());
|
||||
}
|
||||
Ok(qty as usize)
|
||||
});
|
||||
let out = b
|
||||
.morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 7}))
|
||||
.unwrap();
|
||||
@@ -326,11 +303,7 @@ mod tests {
|
||||
#[test]
|
||||
fn records_for_returns_borrowed_view() {
|
||||
let id = Uuid::new_v4();
|
||||
let b = MockBackend::with_records([(
|
||||
"X".into(),
|
||||
id,
|
||||
json!({"k": 1}),
|
||||
)]);
|
||||
let b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
|
||||
let view = b.records_for("X");
|
||||
assert_eq!(view.len(), 1);
|
||||
assert_eq!(view[0].0, id);
|
||||
|
||||
@@ -190,6 +190,10 @@ pub struct FieldSpec {
|
||||
/// Para los demás kinds, este campo se ignora.
|
||||
#[serde(default)]
|
||||
pub ref_entity: Option<String>,
|
||||
/// Opciones de un campo `kind == Select`. Ignorado para los demás
|
||||
/// kinds. `Module::validate` exige que un Select las tenga.
|
||||
#[serde(default)]
|
||||
pub options: Vec<SelectOption>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -210,6 +214,31 @@ pub enum FieldKind {
|
||||
/// `FieldSpec.ref_entity`; el value almacenado es el UUID del
|
||||
/// seleccionado, parseable como cualquier text/UUID al submit.
|
||||
EntityRef,
|
||||
/// Valor elegido de un conjunto cerrado declarado en
|
||||
/// `FieldSpec.options`. El runtime lo renderiza como selección
|
||||
/// (no texto libre). `Module::validate` exige `options` no vacío.
|
||||
Select,
|
||||
/// Identificador autogenerado (UUID v4). El runtime lo rellena al
|
||||
/// abrir el formulario; el usuario no lo teclea ni lo edita. Para
|
||||
/// los ids de idempotencia que piden los morfismos.
|
||||
AutoId,
|
||||
}
|
||||
|
||||
/// Una opción de un campo [`FieldKind::Select`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SelectOption {
|
||||
/// Valor que se guarda (lo que recibe el backend o el morfismo).
|
||||
pub value: String,
|
||||
/// Etiqueta legible. Si se omite, se muestra el `value` crudo.
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
impl SelectOption {
|
||||
/// Texto a mostrar: `label` si está, sino el `value`.
|
||||
pub fn display(&self) -> &str {
|
||||
self.label.as_deref().unwrap_or(&self.value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Acciones disparables por menús, botones o submit de formularios.
|
||||
@@ -307,6 +336,15 @@ pub enum SchemaError {
|
||||
view: String,
|
||||
field: String,
|
||||
},
|
||||
#[error(
|
||||
"módulo {id} vista '{view}': field '{field}' tiene kind=select \
|
||||
pero no declaró options"
|
||||
)]
|
||||
SelectMissingOptions {
|
||||
id: String,
|
||||
view: String,
|
||||
field: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Module {
|
||||
@@ -349,6 +387,13 @@ impl Module {
|
||||
field: f.name.clone(),
|
||||
});
|
||||
}
|
||||
if f.kind == FieldKind::Select && f.options.is_empty() {
|
||||
return Err(SchemaError::SelectMissingOptions {
|
||||
id: self.id.clone(),
|
||||
view: view_key.clone(),
|
||||
field: f.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,6 +464,7 @@ mod tests {
|
||||
required: true,
|
||||
help: None,
|
||||
ref_entity: None,
|
||||
options: Vec::new(),
|
||||
},
|
||||
FieldSpec {
|
||||
name: "email".into(),
|
||||
@@ -428,6 +474,7 @@ mod tests {
|
||||
required: false,
|
||||
help: Some("Opcional".into()),
|
||||
ref_entity: None,
|
||||
options: Vec::new(),
|
||||
},
|
||||
],
|
||||
}],
|
||||
@@ -481,6 +528,7 @@ mod tests {
|
||||
required: true,
|
||||
help: None,
|
||||
ref_entity: None,
|
||||
options: Vec::new(),
|
||||
}],
|
||||
on_submit: Action::SeedEntity {
|
||||
entity: "customer".into(),
|
||||
@@ -573,6 +621,7 @@ mod tests {
|
||||
required: true,
|
||||
help: None,
|
||||
ref_entity: None,
|
||||
options: Vec::new(),
|
||||
}],
|
||||
on_submit: Action::SeedEntity {
|
||||
entity: "customer".into(),
|
||||
@@ -603,6 +652,7 @@ mod tests {
|
||||
required: true,
|
||||
help: None,
|
||||
ref_entity: Some("supplier".into()),
|
||||
options: Vec::new(),
|
||||
}],
|
||||
on_submit: Action::SeedEntity {
|
||||
entity: "customer".into(),
|
||||
@@ -618,6 +668,92 @@ mod tests {
|
||||
m.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_without_options_is_rejected() {
|
||||
let mut m = sample_module();
|
||||
m.views.insert(
|
||||
"sel_form".into(),
|
||||
View::Form(FormView {
|
||||
title: "Select roto".into(),
|
||||
entity: "customer".into(),
|
||||
fields: vec![FieldSpec {
|
||||
name: "estado".into(),
|
||||
label: "Estado".into(),
|
||||
kind: FieldKind::Select,
|
||||
default: None,
|
||||
required: true,
|
||||
help: None,
|
||||
ref_entity: None,
|
||||
options: Vec::new(),
|
||||
}],
|
||||
on_submit: Action::SeedEntity {
|
||||
entity: "customer".into(),
|
||||
next_view: None,
|
||||
},
|
||||
}),
|
||||
);
|
||||
let err = m.validate().unwrap_err();
|
||||
assert!(
|
||||
matches!(err, SchemaError::SelectMissingOptions { .. }),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_with_options_validates_clean() {
|
||||
let mut m = sample_module();
|
||||
m.views.insert(
|
||||
"sel_form".into(),
|
||||
View::Form(FormView {
|
||||
title: "Select OK".into(),
|
||||
entity: "customer".into(),
|
||||
fields: vec![FieldSpec {
|
||||
name: "estado".into(),
|
||||
label: "Estado".into(),
|
||||
kind: FieldKind::Select,
|
||||
default: None,
|
||||
required: true,
|
||||
help: None,
|
||||
ref_entity: None,
|
||||
options: vec![
|
||||
SelectOption {
|
||||
value: "activo".into(),
|
||||
label: Some("Activo".into()),
|
||||
},
|
||||
SelectOption {
|
||||
value: "baja".into(),
|
||||
label: None,
|
||||
},
|
||||
],
|
||||
}],
|
||||
on_submit: Action::SeedEntity {
|
||||
entity: "customer".into(),
|
||||
next_view: None,
|
||||
},
|
||||
}),
|
||||
);
|
||||
m.menu.push(MenuItem {
|
||||
label: "Sel".into(),
|
||||
view: "sel_form".into(),
|
||||
icon: None,
|
||||
});
|
||||
m.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_option_display_falls_back_to_value() {
|
||||
let with_label = SelectOption {
|
||||
value: "x".into(),
|
||||
label: Some("Equis".into()),
|
||||
};
|
||||
let bare = SelectOption {
|
||||
value: "y".into(),
|
||||
label: None,
|
||||
};
|
||||
assert_eq!(with_label.display(), "Equis");
|
||||
assert_eq!(bare.display(), "y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_modules_detects_duplicate_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -626,16 +762,8 @@ mod tests {
|
||||
std::fs::create_dir_all(&a_dir).unwrap();
|
||||
std::fs::create_dir_all(&b_dir).unwrap();
|
||||
let m = sample_module(); // id = "customers"
|
||||
std::fs::write(
|
||||
a_dir.join("module.json"),
|
||||
serde_json::to_vec(&m).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
b_dir.join("module.json"),
|
||||
serde_json::to_vec(&m).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(a_dir.join("module.json"), serde_json::to_vec(&m).unwrap()).unwrap();
|
||||
std::fs::write(b_dir.join("module.json"), serde_json::to_vec(&m).unwrap()).unwrap();
|
||||
let err = load_modules_from_dir(tmp.path()).unwrap_err();
|
||||
assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ fn loads_all_demo_modules() {
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![
|
||||
"crm",
|
||||
"customers",
|
||||
"inventory_movements",
|
||||
"invoices",
|
||||
@@ -34,8 +35,8 @@ fn loads_all_demo_modules() {
|
||||
"sales_orders",
|
||||
"suppliers",
|
||||
],
|
||||
"expected 7 modules in alphabetical order \
|
||||
(sales_engine se sumó al wirear Action::Morphism)"
|
||||
"expected 8 modules in alphabetical order \
|
||||
(crm se sumó como ERP con morfismos)"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user