From fc727266668baca3ad8d9bc6ac4c09ee62b360b3 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 9 May 2026 20:48:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(nakui-ui):=20FieldKind::EntityRef=20?= =?UTF-8?q?=E2=80=94=20selector=20clickable=20de=20records=20existentes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- CHANGELOG.md | 63 +++++++ crates/apps/nakui-ui/src/main.rs | 154 ++++++++++++++++++ crates/modules/nakui/ui-schema/src/lib.rs | 104 +++++++++++- .../nakui-modules/sales_engine/module.json | 4 +- 4 files changed, 321 insertions(+), 4 deletions(-) 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 `. ## 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`** 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 { 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 { } } +/// 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, + 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, + /// 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, } #[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" },