gioser

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definitions

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.

fc727266668baca3ad8d9bc6ac4c09ee62b360b3 · by Sergio · (5 months ago) · parent 932e7464d · browse the tree at this commit

Semantic delta · 15

crates/modules/nakui/ui-schema/src/lib.rs

4 files · +321 −4

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 360382f..07b49df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,69 @@ ratio/diff ver `git show <sha>`.
 
 ## 2026-05-09
 
+### 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 `nakui-ui-schema`:
+- **Nueva variante `FieldKind::EntityRef`** (serializa como
+  `"entity_ref"` en JSON).
+- **`FieldSpec.ref_entity: Option<String>`** nuevo. Indica qué
+  entity ofrecer en el selector. `validate()` chequea que cualquier
+  field con `kind=entity_ref` tenga `ref_entity` set.
+- Nuevo error tipado `SchemaError::EntityRefMissingTarget`.
+
+Runtime `nakui-ui`:
+- **`render_entity_ref_selector(field_name, target_entity, ...)`** —
+  helper que arma la lista debajo del input. Cada item:
+  - Etiqueta humana via `human_label_for_record` (heurística:
+    `name` → `label` → `title` → `sku` → `sku_id` → fallback al
+    UUID corto).
+  - Click handler vía `cx.listener` que llama
+    `input.set_text(uuid_completo)` — el TextInput interno queda
+    como source-of-truth, así que `commit_seed` y `commit_morphism`
+    leen el UUID seleccionado sin saber que vino de un selector.
+  - Highlight en accent color cuando el item es el actualmente
+    seleccionado (compara contra el contenido del TextInput).
+- **`parse_field_value(EntityRef, raw)`** devuelve string del raw
+  (la validación como Uuid ocurre downstream en `commit_morphism`).
+- Mensaje "(sin {entity}: creá uno antes para referenciar)" cuando
+  la lista está vacía — el user sabe qué hacer en lugar de quedarse
+  trabado.
+
+Demo actualizado: `examples/nakui-modules/sales_engine/module.json`:
+- `vender_form.fields.stock_id_input` y `caja_id_input` cambian de
+  `kind: "text"` a `kind: "entity_ref"` con `ref_entity: "Stock"`
+  y `"Caja"` respectivamente.
+- Ahora el flujo "Vender" es: (1) click en una Stock listada bajo
+  el input, (2) click en una Caja, (3) escribir venta_id/cantidad/
+  precio_unitario/timestamp, (4) submit. Sin copiar UUIDs.
+
+Tests:
+- 2 nuevos en schema: `validate_catches_entity_ref_without_target`
+  y `entity_ref_with_target_validates_clean`. 8 totales.
+- 4 nuevos en runtime: `parse_field_entity_ref_returns_string`,
+  `human_label_for_record_prefers_name_over_id`,
+  `human_label_falls_back_through_label_title_sku`,
+  `human_label_falls_back_to_id_when_no_known_keys`. 16 totales.
+- Integration de los 7 demos sigue verde — el demo `sales_engine`
+  ahora valida con EntityRef + ref_entity correctamente set.
+
+29 tests totales nakui-ui + schema, 100% verde. El demo
+`sales_engine` carga limpio con la nueva forma del schema.
+
+Pendientes futuros:
+- **Confirmación de delete** — modal antes de borrar.
+- **Snapshot/compaction** del log para repos grandes.
+- **Edit delta-only** (sólo campos modificados).
+- **Validación de tipos en params del morphism**: `FieldKind`
+  declarado en el FieldSpec se podría usar para forzar parseo
+  estricto en `commit_morphism` en lugar de la heurística
+  `infer_param_value`.
+
 ### feat(nakui-ui): Action::Morphism wired al pipeline real (compute → log → apply)
 Cierra el último gran TODO de la metainterfaz Nakui: las acciones
 `Action::Morphism` ya no son un toast informativo; despachan al
diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs
index 0a1f1ab..2bdd1a4 100644
--- a/crates/apps/nakui-ui/src/main.rs
+++ b/crates/apps/nakui-ui/src/main.rs
@@ -631,6 +631,10 @@ impl MetaUi {
 fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
     match kind {
         FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
+        // EntityRef se almacena como string del UUID seleccionado.
+        // El commit_morphism luego lo parsea como Uuid para inputs;
+        // en seed_entity normal queda como string en el record.
+        FieldKind::EntityRef => Ok(json!(raw)),
         FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
             "true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
             "" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
@@ -648,6 +652,20 @@ fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
     }
 }
 
+/// Etiqueta humana para representar un record en el selector de
+/// EntityRef. Heurística: prefiere campos comunes en este orden:
+/// `name`, `label`, `title`, `sku`, `sku_id`. Fallback al UUID corto.
+fn human_label_for_record(value: &Value, id: &Uuid) -> String {
+    for key in ["name", "label", "title", "sku", "sku_id"] {
+        if let Some(v) = value.get(key).and_then(Value::as_str) {
+            if !v.is_empty() {
+                return format!("{} ({})", v, short_uuid(id));
+            }
+        }
+    }
+    short_uuid(id)
+}
+
 fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
     let mut cur = v;
     for seg in path.split('.') {
@@ -1078,6 +1096,87 @@ impl MetaUi {
         main
     }
 
+    /// Renderea el selector clickable de records existentes para un
+    /// FieldSpec con kind=EntityRef. Lista compacta debajo del input;
+    /// 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.
+    #[allow(clippy::too_many_arguments)]
+    fn render_entity_ref_selector(
+        &self,
+        cx: &mut Context<Self>,
+        field_name: String,
+        target_entity: String,
+        text: gpui::Rgba,
+        text_dim: gpui::Rgba,
+        accent: gpui::Rgba,
+    ) -> gpui::Div {
+        let _ = text;
+        let rows = self.list_rows(&target_entity);
+        let current = self
+            .form_inputs
+            .get(&field_name)
+            .map(|inp| inp.read(&*cx).text().to_string())
+            .unwrap_or_default();
+
+        let mut container = div()
+            .mt(px(4.))
+            .pl(px(8.))
+            .border_l_2()
+            .border_color(gpui::rgb(0x2a2f38))
+            .flex()
+            .flex_col()
+            .gap(px(2.));
+
+        if rows.is_empty() {
+            return container.child(
+                div()
+                    .px(px(6.))
+                    .py(px(4.))
+                    .text_color(text_dim)
+                    .text_size(px(10.))
+                    .child(format!(
+                        "(sin {target_entity}: creá uno antes para referenciar)"
+                    )),
+            );
+        }
+
+        container = container.child(
+            div()
+                .text_color(text_dim)
+                .text_size(px(10.))
+                .child(format!("Seleccioná un {target_entity}:")),
+        );
+
+        for (id, value) in &rows {
+            let label = human_label_for_record(value, id);
+            let id_str = id.to_string();
+            let is_selected = current == id_str;
+            let field_for_click = field_name.clone();
+            let id_for_click = id_str.clone();
+            container = container.child(
+                div()
+                    .id(SharedString::from(format!(
+                        "entity-ref-{field_name}-{id_str}"
+                    )))
+                    .px(px(6.))
+                    .py(px(2.))
+                    .text_size(px(11.))
+                    .text_color(if is_selected { accent } else { text_dim })
+                    .when(is_selected, |d| d.bg(gpui::rgb(0x232a36)))
+                    .hover(|d| d.bg(gpui::rgb(0x1f2630)))
+                    .child(label)
+                    .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(id_for_click.clone(), cx));
+                        }
+                        cx.notify();
+                    })),
+            );
+        }
+        container
+    }
+
     #[allow(clippy::too_many_arguments)]
     fn render_form(
         &self,
@@ -1136,6 +1235,23 @@ impl MetaUi {
                 );
             }
 
+            // 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(
+                        cx,
+                        f.name.clone(),
+                        target_entity.clone(),
+                        text,
+                        text_dim,
+                        accent,
+                    ));
+                }
+            }
+
             if let Some(help) = &f.help {
                 field_box = field_box.child(
                     div()
@@ -1249,6 +1365,44 @@ mod tests {
         assert_eq!(infer_param_value("hola"), json!("hola"));
     }
 
+    #[test]
+    fn parse_field_entity_ref_returns_string() {
+        // EntityRef se almacena como string del UUID. parse_field_value
+        // no lo valida como UUID — eso lo hace commit_morphism al
+        // resolver inputs.
+        let v = parse_field_value(FieldKind::EntityRef, "abc-123").unwrap();
+        assert_eq!(v, json!("abc-123"));
+    }
+
+    #[test]
+    fn human_label_for_record_prefers_name_over_id() {
+        let id = Uuid::new_v4();
+        let with_name = json!({"name": "Acme S.A.", "email": "x@y.z"});
+        let label = human_label_for_record(&with_name, &id);
+        assert!(label.starts_with("Acme S.A."), "got: {label}");
+        assert!(label.contains(&short_uuid(&id)));
+    }
+
+    #[test]
+    fn human_label_falls_back_through_label_title_sku() {
+        let id = Uuid::new_v4();
+        let only_label = json!({"label": "X"});
+        assert!(human_label_for_record(&only_label, &id).starts_with("X "));
+        let only_title = json!({"title": "Y"});
+        assert!(human_label_for_record(&only_title, &id).starts_with("Y "));
+        let only_sku = json!({"sku": "Z-001"});
+        assert!(human_label_for_record(&only_sku, &id).starts_with("Z-001 "));
+        let only_sku_id = json!({"sku_id": "W-002"});
+        assert!(human_label_for_record(&only_sku_id, &id).starts_with("W-002 "));
+    }
+
+    #[test]
+    fn human_label_falls_back_to_id_when_no_known_keys() {
+        let id = Uuid::new_v4();
+        let v = json!({"weird_field": "val"});
+        assert_eq!(human_label_for_record(&v, &id), short_uuid(&id));
+    }
+
     #[test]
     fn render_value_handles_null_string_bool() {
         assert_eq!(render_value(None), "");
diff --git a/crates/modules/nakui/ui-schema/src/lib.rs b/crates/modules/nakui/ui-schema/src/lib.rs
index 0f28f69..82dfd29 100644
--- a/crates/modules/nakui/ui-schema/src/lib.rs
+++ b/crates/modules/nakui/ui-schema/src/lib.rs
@@ -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();
diff --git a/examples/nakui-modules/sales_engine/module.json b/examples/nakui-modules/sales_engine/module.json
index 1a0189b..2e528d5 100644
--- a/examples/nakui-modules/sales_engine/module.json
+++ b/examples/nakui-modules/sales_engine/module.json
@@ -127,8 +127,8 @@
       "title": "Vender (morphism)",
       "entity": "Venta",
       "fields": [
-        { "name": "stock_id_input", "label": "Stock UUID", "kind": "text", "required": true, "help": "Copiá el UUID corto de la lista de Stock — el runtime lo parsea como Uuid completo si es válido." },
-        { "name": "caja_id_input", "label": "Caja UUID", "kind": "text", "required": true, "help": "Idem para Caja." },
+        { "name": "stock_id_input", "label": "Stock", "kind": "entity_ref", "ref_entity": "Stock", "required": true, "help": "Click en un Stock de la lista para seleccionar." },
+        { "name": "caja_id_input", "label": "Caja", "kind": "entity_ref", "ref_entity": "Caja", "required": true, "help": "Click en una Caja de la lista para seleccionar." },
         { "name": "venta_id", "label": "Venta UUID (idempotencia)", "kind": "text", "required": true, "help": "UUID nuevo por cada intento; mismo UUID = idempotente." },
         { "name": "cantidad", "label": "Cantidad a vender", "kind": "number", "required": true, "default": "1" },
         { "name": "precio_unitario", "label": "Precio unitario", "kind": "number", "required": true, "default": "0" },