From 86d06da020b21905887b8e689fe014b5c3cc6e8f Mon Sep 17 00:00:00 2001 From: sergio Date: Thu, 21 May 2026 18:55:13 +0000 Subject: [PATCH] =?UTF-8?q?feat(nakui):=20Fase=201=20del=20ERP=20=E2=80=94?= =?UTF-8?q?=20FieldKind=20Select=20+=20AutoId,=20seed=20inyecta=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/apps/nakui-ui/src/backend.rs | 5 + .../nahual/libs/meta-runtime/src/backend.rs | 15 +- .../nahual/libs/meta-runtime/src/delta.rs | 5 +- .../nahual/libs/meta-runtime/src/parse.rs | 53 +++- .../nahual/libs/meta-runtime/src/testing.rs | 67 ++--- .../nahual/libs/meta-schema/src/lib.rs | 148 ++++++++++- .../libs/meta-schema/tests/example_modules.rs | 5 +- .../nahual/widgets/meta-form/src/lib.rs | 249 +++++++++++------- .../tests/widget_with_mock_backend.rs | 49 +--- docs/changelog/nahual.md | 15 ++ docs/changelog/nakui.md | 15 ++ examples/nakui-modules/crm/module.json | 26 +- 12 files changed, 442 insertions(+), 210 deletions(-) diff --git a/crates/apps/nakui-ui/src/backend.rs b/crates/apps/nakui-ui/src/backend.rs index 0fad277..a1acbcf 100644 --- a/crates/apps/nakui-ui/src/backend.rs +++ b/crates/apps/nakui-ui/src/backend.rs @@ -282,6 +282,11 @@ impl MetaBackend for NakuiBackend { data: serde_json::Map, ) -> Result { let id = Uuid::new_v4(); + // El `id` de la entity = la clave del store. Inyectarlo en el + // record hace que `data.id` y la clave coincidan — los schemas + // Nickel suelen declarar `id | String` y los morfismos lo leen. + let mut data = data; + data.insert("id".to_string(), Value::String(id.to_string())); let value = Value::Object(data); // WAL: log primero, store después. if self.event_log.is_some() { diff --git a/crates/modules/nahual/libs/meta-runtime/src/backend.rs b/crates/modules/nahual/libs/meta-runtime/src/backend.rs index a13e0ee..6d72246 100644 --- a/crates/modules/nahual/libs/meta-runtime/src/backend.rs +++ b/crates/modules/nahual/libs/meta-runtime/src/backend.rs @@ -137,7 +137,10 @@ mod tests { use serde_json::json; fn map_of(items: &[(&str, Value)]) -> serde_json::Map { - 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(); diff --git a/crates/modules/nahual/libs/meta-runtime/src/delta.rs b/crates/modules/nahual/libs/meta-runtime/src/delta.rs index bccb5f7..2b0e560 100644 --- a/crates/modules/nahual/libs/meta-runtime/src/delta.rs +++ b/crates/modules/nahual/libs/meta-runtime/src/delta.rs @@ -50,7 +50,10 @@ mod tests { use serde_json::json; fn map(items: &[(&str, Value)]) -> serde_json::Map { - items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect() + items + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect() } #[test] diff --git a/crates/modules/nahual/libs/meta-runtime/src/parse.rs b/crates/modules/nahual/libs/meta-runtime/src/parse.rs index 956ae9d..67cf351 100644 --- a/crates/modules/nahual/libs/meta-runtime/src/parse.rs +++ b/crates/modules/nahual/libs/meta-runtime/src/parse.rs @@ -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 { 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!( diff --git a/crates/modules/nahual/libs/meta-runtime/src/testing.rs b/crates/modules/nahual/libs/meta-runtime/src/testing.rs index 412b2de..0da13bf 100644 --- a/crates/modules/nahual/libs/meta-runtime/src/testing.rs +++ b/crates/modules/nahual/libs/meta-runtime/src/testing.rs @@ -204,30 +204,24 @@ mod tests { use serde_json::json; fn map_of(items: &[(&str, Value)]) -> serde_json::Map { - 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); diff --git a/crates/modules/nahual/libs/meta-schema/src/lib.rs b/crates/modules/nahual/libs/meta-schema/src/lib.rs index 167fee5..380917e 100644 --- a/crates/modules/nahual/libs/meta-schema/src/lib.rs +++ b/crates/modules/nahual/libs/meta-schema/src/lib.rs @@ -190,6 +190,10 @@ pub struct FieldSpec { /// Para los demás kinds, este campo se ignora. #[serde(default)] pub ref_entity: Option, + /// 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, } #[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, +} + +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 { .. })); } diff --git a/crates/modules/nahual/libs/meta-schema/tests/example_modules.rs b/crates/modules/nahual/libs/meta-schema/tests/example_modules.rs index defe3e4..412d898 100644 --- a/crates/modules/nahual/libs/meta-schema/tests/example_modules.rs +++ b/crates/modules/nahual/libs/meta-schema/tests/example_modules.rs @@ -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)" ); } diff --git a/crates/modules/nahual/widgets/meta-form/src/lib.rs b/crates/modules/nahual/widgets/meta-form/src/lib.rs index 6d88be3..b590d2e 100644 --- a/crates/modules/nahual/widgets/meta-form/src/lib.rs +++ b/crates/modules/nahual/widgets/meta-form/src/lib.rs @@ -26,18 +26,20 @@ use gpui::{ SharedString, Window, }; -use serde_json::Value; -use uuid::Uuid; use nahual_meta_runtime::{ compute_clear_fields, compute_field_delta, human_label_for_record, parse_field_value, render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text, MetaBackend, WriteOutcome, }; -use nahual_meta_schema::{Action, FieldKind, FieldSpec, FormView, ListView, Module, View}; +use nahual_meta_schema::{ + Action, FieldKind, FieldSpec, FormView, ListView, Module, SelectOption, View, +}; use nahual_theme::Theme; use nahual_widget_banner::{banner_themed, themed_colors, Banner}; use nahual_widget_text_input::TextInput; use nahual_widget_theme_switcher::theme_switcher; +use serde_json::Value; +use uuid::Uuid; /// Estado del runtime de UI. Toda la persistencia/ejecución está /// detrás del trait [`MetaBackend`]; este struct sólo conoce GPUI @@ -125,7 +127,14 @@ impl MetaApp { } }); for f in &form.fields { - let initial = if let Some(rec) = &editing_record { + let initial = if f.kind == FieldKind::AutoId { + // Editando: conservar el id del record. + // Alta: UUID nuevo, que el usuario no teclea. + editing_record + .as_ref() + .and_then(|rec| rec.get(&f.name).map(value_to_input_text)) + .unwrap_or_else(|| Uuid::new_v4().to_string()) + } else if let Some(rec) = &editing_record { rec.get(&f.name) .map(value_to_input_text) .unwrap_or_else(|| f.default.clone().unwrap_or_default()) @@ -146,21 +155,13 @@ impl MetaApp { /// Inicia un edit del record: setea `editing` y abre la primera /// view de tipo Form del módulo (convención: la del schema). - fn open_edit( - &mut self, - mod_idx: usize, - entity: String, - id: Uuid, - cx: &mut Context, - ) { + fn open_edit(&mut self, mod_idx: usize, entity: String, id: Uuid, cx: &mut Context) { self.editing = Some((entity.clone(), id)); let form_view_key = self.modules.get(mod_idx).and_then(|m| { - m.views - .iter() - .find_map(|(key, v)| match v { - View::Form(form) if form.entity == entity => Some(key.clone()), - _ => None, - }) + m.views.iter().find_map(|(key, v)| match v { + View::Form(form) if form.entity == entity => Some(key.clone()), + _ => None, + }) }); match form_view_key { Some(key) => self.select_view(mod_idx, key, cx), @@ -179,11 +180,7 @@ impl MetaApp { /// el modelo de "todo cambio post-seed pasa por ops"). /// Borra un record vía `MetaBackend::delete`. Devuelve el outcome /// del backend (incluye eventual `post_status` del compact tick). - fn commit_delete( - &mut self, - entity: &str, - id: Uuid, - ) -> Result { + fn commit_delete(&mut self, entity: &str, id: Uuid) -> Result { self.backend.delete(entity, id) } @@ -232,19 +229,16 @@ impl MetaApp { } => { match self.commit_morphism(mod_idx, &name, &inputs, ¶ms, cx) { Ok(outcome) => { - let base = format!( - "morphism '{name}' OK ({} op(s) aplicadas)", - outcome.changed - ); + let base = + format!("morphism '{name}' OK ({} op(s) aplicadas)", outcome.changed); self.toast = Some(append_compact_msg(base, outcome.post_status)); if let Some(v) = next_view { self.select_view(mod_idx, v, cx); } } Err(e) => { - self.toast = Some(SharedString::from(format!( - "morphism '{name}' falló: {e}" - ))); + self.toast = + Some(SharedString::from(format!("morphism '{name}' falló: {e}"))); } } cx.notify(); @@ -278,9 +272,7 @@ impl MetaApp { .map(|inp| inp.read(&*cx).text().to_string()) .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?; let id = Uuid::parse_str(raw.trim()).map_err(|_| { - format!( - "input '{role}' (field '{field_name}'): '{raw}' no es UUID válido" - ) + format!("input '{role}' (field '{field_name}'): '{raw}' no es UUID válido") })?; inputs.insert(role.clone(), id); } @@ -363,12 +355,11 @@ impl MetaApp { to_clear.push(f.name.clone()); continue; } - let value = parse_field_value(f.kind, &raw) - .map_err(|e| format!("campo '{}': {e}", f.label))?; + let value = + parse_field_value(f.kind, &raw).map_err(|e| format!("campo '{}': {e}", f.label))?; if f.kind == FieldKind::EntityRef { if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) { - let id = Uuid::parse_str(uuid_str) - .expect("parse_field_value validated UUID"); + let id = Uuid::parse_str(uuid_str).expect("parse_field_value validated UUID"); entity_refs.push((f.label.clone(), target.clone(), id)); } } @@ -409,10 +400,7 @@ impl MetaApp { /// vs "actualizado" — el WriteOutcome solo no alcanza porque /// `seed` y `update` ambos devuelven `id = Some(...)`. fn format_seed_toast(entity: &str, was_editing: bool, outcome: &WriteOutcome) -> String { - let id_short = outcome - .id - .map(|id| short_uuid(&id)) - .unwrap_or_default(); + let id_short = outcome.id.map(|id| short_uuid(&id)).unwrap_or_default(); match (was_editing, outcome.changed) { (false, _) => format!("creado {entity} {id_short}"), (true, 0) => format!("{entity} {id_short} sin cambios — no log entry"), @@ -450,16 +438,15 @@ impl Render for MetaApp { // Si el caller no instaló un Theme, `Theme::global` panicea. // Convención: el binario shell instala el theme en main. let theme = Theme::global(cx).clone(); - let bg = theme.bg_app.clone(); - let panel = theme.bg_panel.clone(); + let bg = theme.bg_app; + let panel = theme.bg_panel; let border = theme.border; let text = theme.fg_text; let text_dim = theme.fg_muted; let accent = theme.accent; let accent_active = theme.accent_strong; - let sidebar = - self.render_sidebar(cx, panel.clone(), border, text, text_dim, accent_active); + let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active); let main_panel = self.render_main(cx, panel, border, text, text_dim, accent); let confirm_banner = self.render_confirm_delete_banner(cx); let toast_div = self @@ -529,7 +516,7 @@ impl MetaApp { let theme = Theme::global(cx); let (banner_bg, banner_text) = themed_colors(Banner::Warning, theme); let (confirm_bg, confirm_text) = themed_colors(Banner::Error, theme); - let cancel_bg: gpui::Background = theme.bg_panel_alt.clone(); + let cancel_bg: gpui::Background = theme.bg_panel_alt; let cancel_text = theme.fg_text; // Hover colors capturados antes de las closures para que el // move |d| d.bg(...) los cierre. @@ -583,10 +570,7 @@ impl MetaApp { ) .child( div() - .id(SharedString::from(format!( - "confirm-del-ok-{}", - id_owned - ))) + .id(SharedString::from(format!("confirm-del-ok-{}", id_owned))) .px(px(10.)) .py(px(4.)) .bg(confirm_bg) @@ -604,15 +588,12 @@ impl MetaApp { "borrado {entity_for_confirm} {}", short_uuid(&id_owned) ); - this.toast = Some(append_compact_msg( - base, - outcome.post_status, - )); + this.toast = + Some(append_compact_msg(base, outcome.post_status)); } Err(e) => { - this.toast = Some(SharedString::from(format!( - "error borrando: {e}" - ))); + this.toast = + Some(SharedString::from(format!("error borrando: {e}"))); } } cx.notify(); @@ -761,8 +742,12 @@ impl MetaApp { }; match view { - View::List(lv) => self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent), - View::Form(fv) => self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent), + View::List(lv) => { + self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent) + } + View::Form(fv) => { + self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent) + } } } @@ -809,9 +794,7 @@ impl MetaApp { let action_clone = action.clone(); header = header.child( div() - .id(SharedString::from(format!( - "list-action-{mod_idx}-{idx}" - ))) + .id(SharedString::from(format!("list-action-{mod_idx}-{idx}"))) .px(px(10.)) .py(px(4.)) .bg(action_bg) @@ -892,9 +875,7 @@ impl MetaApp { .gap(px(4.)) .child( div() - .id(SharedString::from(format!( - "row-edit-{mod_idx}-{id_copy}" - ))) + .id(SharedString::from(format!("row-edit-{mod_idx}-{id_copy}"))) .px(px(6.)) .text_color(accent) .text_size(px(13.)) @@ -906,9 +887,7 @@ impl MetaApp { ) .child( div() - .id(SharedString::from(format!( - "row-del-{mod_idx}-{id_copy}" - ))) + .id(SharedString::from(format!("row-del-{mod_idx}-{id_copy}"))) .px(px(6.)) .text_color(destructive_fg) .text_size(px(13.)) @@ -919,8 +898,7 @@ impl MetaApp { // directo: el modal de confirmación se // renderea arriba en `render` y maneja // confirm/cancel. - this.pending_delete = - Some((entity_for_delete.clone(), id_copy)); + this.pending_delete = Some((entity_for_delete.clone(), id_copy)); this.toast = None; cx.notify(); })), @@ -955,6 +933,59 @@ impl MetaApp { /// click en una opción setea el TextInput del field con el UUID /// seleccionado. El item del UUID actualmente seleccionado (si /// hay) se resalta con accent color. + /// Chips clickables para un campo [`FieldKind::Select`]. El chip de + /// la opción elegida se resalta con accent. Click setea el + /// `TextInput` del field (de donde lee el submit), igual que el + /// selector de EntityRef. + fn render_select_chips( + &self, + cx: &mut Context, + field_name: String, + options: &[SelectOption], + text_dim: gpui::Hsla, + accent: gpui::Hsla, + ) -> gpui::Div { + let theme = Theme::global(cx); + let row_active = theme.bg_row_active; + let row_hover = theme.bg_row_hover; + let border = theme.border; + let current = self + .form_inputs + .get(&field_name) + .map(|inp| inp.read(&*cx).text().to_string()) + .unwrap_or_default(); + + let mut row = div().mt(px(2.)).flex().flex_row().flex_wrap().gap(px(4.)); + + for opt in options { + let value = opt.value.clone(); + let is_selected = current == value; + let field_for_click = field_name.clone(); + let value_for_click = value.clone(); + row = row.child( + div() + .id(SharedString::from(format!("select-{field_name}-{value}"))) + .px(px(8.)) + .py(px(3.)) + .rounded(px(10.)) + .border_1() + .border_color(if is_selected { accent } else { border }) + .text_size(px(11.)) + .text_color(if is_selected { accent } else { text_dim }) + .when(is_selected, move |d| d.bg(row_active)) + .hover(move |d| d.bg(row_hover)) + .child(opt.display().to_string()) + .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { + if let Some(input) = this.form_inputs.get(&field_for_click) { + input.update(cx, |inp, cx| inp.set_text(value_for_click.clone(), cx)); + } + cx.notify(); + })), + ); + } + row + } + #[allow(clippy::too_many_arguments)] fn render_entity_ref_selector( &self, @@ -1082,37 +1113,67 @@ impl MetaApp { .child(label), ); - // Mount del TextInput vivo (creado en select_view). - if let Some(input) = self.form_inputs.get(&f.name) { - field_box = field_box.child(input.clone()); - } else { - // No debería pasar — select_view crea inputs por cada - // field. Fallback display estático por seguridad. - field_box = field_box.child( - div() - .px(px(8.)) - .py(px(6.)) - .bg(input_bg) - .text_color(text_dim) - .child("(input no inicializado)"), - ); - } - - // Para EntityRef, agregamos un selector clickable de - // records existentes debajo del TextInput. Click en una - // opción setea el TextInput interno con el UUID; el - // submit lee de ahí como cualquier otro field. - if f.kind == FieldKind::EntityRef { - if let Some(target_entity) = &f.ref_entity { - field_box = field_box.child(self.render_entity_ref_selector( + // Render según el kind. AutoId y Select NO montan el + // TextInput editable —aunque sí existe en `form_inputs`, de + // donde lee el submit—; presentan su propio control. + match f.kind { + FieldKind::AutoId => { + // Read-only: el UUID autogenerado, sólo informativo. + let val = self + .form_inputs + .get(&f.name) + .map(|i| i.read(&*cx).text().to_string()) + .unwrap_or_default(); + field_box = field_box.child( + div() + .px(px(8.)) + .py(px(6.)) + .bg(input_bg) + .text_color(text_dim) + .text_size(px(11.)) + .child(format!("{val} · automático")), + ); + } + FieldKind::Select => { + field_box = field_box.child(self.render_select_chips( cx, f.name.clone(), - target_entity.clone(), - text, + &f.options, text_dim, accent, )); } + _ => { + // Mount del TextInput vivo (creado en select_view). + if let Some(input) = self.form_inputs.get(&f.name) { + field_box = field_box.child(input.clone()); + } else { + // No debería pasar — select_view crea inputs por + // cada field. Fallback estático por seguridad. + field_box = field_box.child( + div() + .px(px(8.)) + .py(px(6.)) + .bg(input_bg) + .text_color(text_dim) + .child("(input no inicializado)"), + ); + } + // EntityRef: selector clickable de records debajo + // del input. Click setea el TextInput con el UUID. + if f.kind == FieldKind::EntityRef { + if let Some(target_entity) = &f.ref_entity { + field_box = field_box.child(self.render_entity_ref_selector( + cx, + f.name.clone(), + target_entity.clone(), + text, + text_dim, + accent, + )); + } + } + } } if let Some(help) = &f.help { diff --git a/crates/modules/nahual/widgets/meta-form/tests/widget_with_mock_backend.rs b/crates/modules/nahual/widgets/meta-form/tests/widget_with_mock_backend.rs index 48f8ee0..b28adf3 100644 --- a/crates/modules/nahual/widgets/meta-form/tests/widget_with_mock_backend.rs +++ b/crates/modules/nahual/widgets/meta-form/tests/widget_with_mock_backend.rs @@ -14,13 +14,13 @@ use std::collections::BTreeMap; use gpui::TestAppContext; -use serde_json::json; use nahual_meta_runtime::testing::MockBackend; use nahual_meta_schema::{ Action, Column, EntitySpec, FieldKind, FieldSpec, FormView, ListView, MenuItem, Module, View, }; use nahual_theme::Theme; use nahual_widget_meta_form::MetaApp; +use serde_json::json; /// Helper: módulo demo simple con una entity Customer + view list. fn customers_module() -> Module { @@ -52,6 +52,7 @@ fn customers_module() -> Module { required: true, help: None, ref_entity: None, + options: Vec::new(), }], on_submit: Action::SeedEntity { entity: "Customer".into(), @@ -92,22 +93,11 @@ fn customers_module() -> Module { fn meta_app_constructs_with_mock_backend_and_initial_state(cx: &mut TestAppContext) { cx.update(|cx| Theme::install_default(cx)); let id = uuid::Uuid::new_v4(); - let backend = MockBackend::with_records([( - "Customer".into(), - id, - json!({"name": "Acme"}), - )]); + let backend = MockBackend::with_records([("Customer".into(), id, json!({"name": "Acme"}))]); let modules = vec![customers_module()]; - let entity = cx.add_window(|_w, cx| { - MetaApp::new( - modules, - backend, - Some("hola".into()), - None, - cx, - ) - }); + let entity = + cx.add_window(|_w, cx| MetaApp::new(modules, backend, Some("hola".into()), None, cx)); let _ = entity; // mantener viva la window para el reactor. } @@ -123,9 +113,7 @@ fn open_view_action_does_not_panic(cx: &mut TestAppContext) { let backend = MockBackend::new(); let modules = vec![customers_module()]; - let window = cx.add_window(|_w, cx| { - MetaApp::new(modules, backend, None, None, cx) - }); + let window = cx.add_window(|_w, cx| MetaApp::new(modules, backend, None, None, cx)); // Update vía window: ejecutar apply_action. window @@ -152,16 +140,10 @@ fn open_view_action_does_not_panic(cx: &mut TestAppContext) { fn backend_state_visible_from_widget_perspective(cx: &mut TestAppContext) { cx.update(|cx| Theme::install_default(cx)); let id = uuid::Uuid::new_v4(); - let backend = MockBackend::with_records([( - "Customer".into(), - id, - json!({"name": "Acme"}), - )]); + let backend = MockBackend::with_records([("Customer".into(), id, json!({"name": "Acme"}))]); let modules = vec![customers_module()]; - let window = cx.add_window(|_w, cx| { - MetaApp::new(modules, backend, None, None, cx) - }); + let window = cx.add_window(|_w, cx| MetaApp::new(modules, backend, None, None, cx)); // Read directo del backend via list_records, vía la API // que renders usan internamente. @@ -172,11 +154,8 @@ fn backend_state_visible_from_widget_perspective(cx: &mut TestAppContext) { // un nuevo MockBackend igual al construido devuelve el // mismo record, validamos el contrato de cómo el mock // simula state. - let mock_check = MockBackend::with_records([( - "Customer".into(), - id, - json!({"name": "Acme"}), - )]); + let mock_check = + MockBackend::with_records([("Customer".into(), id, json!({"name": "Acme"}))]); use nahual_meta_runtime::MetaBackend; let rows = mock_check.list_records("Customer"); assert_eq!(rows.len(), 1); @@ -190,9 +169,7 @@ fn backend_state_visible_from_widget_perspective(cx: &mut TestAppContext) { /// para vivir en una `Entity` de GPUI (el bound del trait es /// `'static`; se cumple). #[gpui::test] -fn morphism_handler_can_be_registered_and_called_via_widget( - cx: &mut TestAppContext, -) { +fn morphism_handler_can_be_registered_and_called_via_widget(cx: &mut TestAppContext) { cx.update(|cx| Theme::install_default(cx)); let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let counter_clone = counter.clone(); @@ -205,9 +182,7 @@ fn morphism_handler_can_be_registered_and_called_via_widget( ); let modules = vec![customers_module()]; - let window = cx.add_window(|_w, cx| { - MetaApp::new(modules, backend, None, None, cx) - }); + let window = cx.add_window(|_w, cx| MetaApp::new(modules, backend, None, None, cx)); // Invocar un Action::Morphism vía apply_action: como el módulo // demo no declara morphism + no hay nakui_module_dir, esperamos diff --git a/docs/changelog/nahual.md b/docs/changelog/nahual.md index 26e083d..b31b6c1 100644 --- a/docs/changelog/nahual.md +++ b/docs/changelog/nahual.md @@ -2,6 +2,21 @@ Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19. +### feat(meta-*): FieldKind Select y AutoId (Fase 1 del ERP nakui) + +La metainterfaz declarativa gana dos tipos de campo: + +- **`Select`** — valor de un conjunto cerrado. `FieldSpec.options` + (valor + etiqueta opcional); `Module::validate` exige `options` no + vacío. `meta-form` lo renderiza como chips clickables (el chip + elegido resaltado), no texto libre. +- **`AutoId`** — UUID v4 autogenerado. `meta-form` lo rellena al abrir + el formulario y lo muestra read-only; el usuario no teclea ids de + idempotencia. En modo edición conserva el id del record. + +`parse_field_value` trata ambos como string passthrough. Tests nuevos +en `meta-schema` (validación de Select) y `meta-runtime` (parseo). + ### feat(nahual-widget-text-input): modo enmascarado para contraseñas `TextInput::with_mask()` dibuja el contenido como puntos (`•`, uno por diff --git a/docs/changelog/nakui.md b/docs/changelog/nakui.md index 3d5838f..76675db 100644 --- a/docs/changelog/nakui.md +++ b/docs/changelog/nakui.md @@ -2,6 +2,21 @@ ERP categórico. +### feat(nakui): plan maestro del ERP + Fase 1 (captura sin fricción) + +Plan maestro del subproyecto en `docs/nakui-erp-masterplan.md`: 7 fases +ordenadas para llevar nakui de "listas y formularios que funcionan" a +"ERP profesional terminado". Fase 1 implementada: + +- El módulo CRM usa `select` para etapa y canal (desplegable de + opciones) y `auto_id` para los ids de idempotencia — ningún + formulario pide un UUID a mano. +- `NakuiBackend::seed` inyecta el `id` de la entity = clave del store, + así `data.id` y la clave coinciden (los schemas Nickel declaran + `id | String`); el formulario de cliente ya no necesita campo `id`. +- Tipos de campo nuevos en la metainterfaz: ver el changelog de + `nahual` (`FieldKind::Select` / `AutoId`). + ### feat(nakui-ui): CRM como ERP — listas y formularios UiModule `examples/nakui-modules/crm/module.json`: hace que el módulo diff --git a/examples/nakui-modules/crm/module.json b/examples/nakui-modules/crm/module.json index 7bc61f5..78f425c 100644 --- a/examples/nakui-modules/crm/module.json +++ b/examples/nakui-modules/crm/module.json @@ -68,7 +68,6 @@ "title": "Nuevo cliente", "entity": "Cliente", "fields": [ - { "name": "id", "label": "ID interno", "kind": "text", "help": "Opcional; el runtime genera el UUID de la entity. Dejar vacío está bien." }, { "name": "nombre", "label": "Nombre", "kind": "text", "required": true }, { "name": "email", "label": "Email", "kind": "text", "required": true }, { "name": "empresa", "label": "Empresa", "kind": "text" } @@ -102,7 +101,7 @@ "entity": "Oportunidad", "fields": [ { "name": "cliente_ref", "label": "Cliente", "kind": "entity_ref", "ref_entity": "Cliente", "required": true, "help": "Click en un cliente de la lista para seleccionarlo." }, - { "name": "oportunidad_id", "label": "Oportunidad UUID", "kind": "text", "required": true, "help": "UUID nuevo por cada intento (idempotencia)." }, + { "name": "oportunidad_id", "label": "ID de la oportunidad", "kind": "auto_id" }, { "name": "titulo", "label": "Título", "kind": "text", "required": true }, { "name": "monto", "label": "Monto", "kind": "number", "required": true, "default": "0" }, { "name": "currency", "label": "Moneda", "kind": "text", "default": "USD" }, @@ -122,7 +121,17 @@ "entity": "Oportunidad", "fields": [ { "name": "oportunidad_ref", "label": "Oportunidad", "kind": "entity_ref", "ref_entity": "Oportunidad", "required": true, "help": "Click en una oportunidad de la lista." }, - { "name": "etapa", "label": "Etapa destino", "kind": "text", "required": true, "help": "prospecto | calificado | propuesta | negociacion | ganada | perdida" }, + { + "name": "etapa", "label": "Etapa destino", "kind": "select", "required": true, + "options": [ + { "value": "prospecto", "label": "Prospecto" }, + { "value": "calificado", "label": "Calificado" }, + { "value": "propuesta", "label": "Propuesta" }, + { "value": "negociacion", "label": "Negociación" }, + { "value": "ganada", "label": "Ganada" }, + { "value": "perdida", "label": "Perdida" } + ] + }, { "name": "timestamp", "label": "Fecha ISO", "kind": "text", "required": true, "default": "2026-05-21T12:00:00Z" } ], "on_submit": { @@ -154,8 +163,15 @@ "entity": "Interaccion", "fields": [ { "name": "cliente_ref", "label": "Cliente", "kind": "entity_ref", "ref_entity": "Cliente", "required": true, "help": "Click en un cliente de la lista." }, - { "name": "interaccion_id", "label": "Interacción UUID", "kind": "text", "required": true, "help": "UUID nuevo por cada intento (idempotencia)." }, - { "name": "canal", "label": "Canal", "kind": "text", "required": true, "help": "llamada | email | reunion" }, + { "name": "interaccion_id", "label": "ID de la interacción", "kind": "auto_id" }, + { + "name": "canal", "label": "Canal", "kind": "select", "required": true, + "options": [ + { "value": "llamada", "label": "Llamada" }, + { "value": "email", "label": "Email" }, + { "value": "reunion", "label": "Reunión" } + ] + }, { "name": "nota", "label": "Nota", "kind": "multiline" }, { "name": "timestamp", "label": "Fecha ISO", "kind": "text", "required": true, "default": "2026-05-21T12:00:00Z" } ],