diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs index d2cacf8..ee400c4 100644 --- a/crates/apps/nakui-ui/src/main.rs +++ b/crates/apps/nakui-ui/src/main.rs @@ -461,6 +461,25 @@ mod tests { "el panorama debe tener al menos un breakdown", ); + // Fase 7: el formulario «abrir_form» agrupa sus campos en + // secciones. + let nahual_meta_schema::View::Form(abrir) = &m.views["abrir_form"] else { + panic!("abrir_form debe ser un formulario"); + }; + assert!( + abrir.fields.iter().all(|f| f.section.is_some()), + "todos los campos de abrir_form deben tener sección", + ); + let secciones: std::collections::BTreeSet<&str> = abrir + .fields + .iter() + .filter_map(|f| f.section.as_deref()) + .collect(); + assert!( + secciones.len() >= 2, + "abrir_form debe tener varias secciones" + ); + // Fase 2: la lista de oportunidades resuelve `cliente_id` al // label del cliente y formatea `monto` como moneda. let nahual_meta_schema::View::List(lv) = &m.views["oportunidad_list"] else { diff --git a/crates/modules/nahual/libs/meta-runtime/src/parse.rs b/crates/modules/nahual/libs/meta-runtime/src/parse.rs index 67cf351..5a94aba 100644 --- a/crates/modules/nahual/libs/meta-runtime/src/parse.rs +++ b/crates/modules/nahual/libs/meta-runtime/src/parse.rs @@ -122,6 +122,7 @@ mod tests { help: None, ref_entity: None, options: Vec::new(), + section: None, } } diff --git a/crates/modules/nahual/libs/meta-schema/src/lib.rs b/crates/modules/nahual/libs/meta-schema/src/lib.rs index 2c8054d..12007c8 100644 --- a/crates/modules/nahual/libs/meta-schema/src/lib.rs +++ b/crates/modules/nahual/libs/meta-schema/src/lib.rs @@ -297,6 +297,10 @@ pub struct FieldSpec { /// kinds. `Module::validate` exige que un Select las tenga. #[serde(default)] pub options: Vec, + /// Sección del formulario a la que pertenece el campo. Campos + /// consecutivos con la misma sección se agrupan bajo un encabezado. + #[serde(default)] + pub section: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -591,6 +595,7 @@ mod tests { help: None, ref_entity: None, options: Vec::new(), + section: None, }, FieldSpec { name: "email".into(), @@ -601,6 +606,7 @@ mod tests { help: Some("Opcional".into()), ref_entity: None, options: Vec::new(), + section: None, }, ], }], @@ -660,6 +666,7 @@ mod tests { help: None, ref_entity: None, options: Vec::new(), + section: None, }], on_submit: Action::SeedEntity { entity: "customer".into(), @@ -753,6 +760,7 @@ mod tests { help: None, ref_entity: None, options: Vec::new(), + section: None, }], on_submit: Action::SeedEntity { entity: "customer".into(), @@ -784,6 +792,7 @@ mod tests { help: None, ref_entity: Some("supplier".into()), options: Vec::new(), + section: None, }], on_submit: Action::SeedEntity { entity: "customer".into(), @@ -816,6 +825,7 @@ mod tests { help: None, ref_entity: None, options: Vec::new(), + section: None, }], on_submit: Action::SeedEntity { entity: "customer".into(), @@ -856,6 +866,7 @@ mod tests { label: None, }, ], + section: None, }], on_submit: Action::SeedEntity { entity: "customer".into(), diff --git a/crates/modules/nahual/widgets/meta-form/src/lib.rs b/crates/modules/nahual/widgets/meta-form/src/lib.rs index 4fbae16..34d1766 100644 --- a/crates/modules/nahual/widgets/meta-form/src/lib.rs +++ b/crates/modules/nahual/widgets/meta-form/src/lib.rs @@ -85,6 +85,10 @@ pub struct MetaApp { list_search: Option>, list_sort: Option<(String, bool)>, list_page: usize, + /// Errores de validación por campo del formulario activo + /// (nombre → mensaje). Se llenan al fallar un submit y el form los + /// muestra inline, debajo del campo. + form_errors: BTreeMap, /// Mensaje toast al pie (success de submit, error de carga, etc.). toast: Option, /// Si la carga de módulos falló al inicio. @@ -121,6 +125,7 @@ impl MetaApp { list_search: None, list_sort: None, list_page: 0, + form_errors: BTreeMap::new(), toast: initial_toast.map(SharedString::from), load_error: initial_error.map(SharedString::from), } @@ -139,6 +144,7 @@ impl MetaApp { self.pending_delete = None; self.detail_target = None; self.form_inputs = BTreeMap::new(); + self.form_errors.clear(); self.list_search = None; self.list_sort = None; self.list_page = 0; @@ -209,6 +215,7 @@ impl MetaApp { self.editing = None; self.pending_delete = None; self.form_inputs = BTreeMap::new(); + self.form_errors.clear(); self.list_search = None; self.list_sort = None; self.list_page = 0; @@ -216,6 +223,34 @@ impl MetaApp { cx.notify(); } + /// Revisa los campos `required` del formulario activo: devuelve un + /// mapa nombre→error con los que están vacíos. Mapa vacío = todo OK. + /// `AutoId` se omite — se autogenera, nunca está vacío. + fn validate_required_fields(&self, cx: &mut Context) -> BTreeMap { + let mut errors = BTreeMap::new(); + let Some(View::Form(fv)) = self + .active + .as_ref() + .and_then(|(i, vk)| self.modules.get(*i).and_then(|m| m.views.get(vk))) + else { + return errors; + }; + for f in &fv.fields { + if !f.required || f.kind == FieldKind::AutoId { + continue; + } + let empty = self + .form_inputs + .get(&f.name) + .map(|i| i.read(cx).text().trim().is_empty()) + .unwrap_or(true); + if empty { + errors.insert(f.name.clone(), "este campo es obligatorio".to_string()); + } + } + errors + } + /// Cambia el orden de la lista al hacer clic en un header: misma /// columna cicla ascendente → descendente → sin orden. fn toggle_sort(&mut self, field: &str, cx: &mut Context) { @@ -269,6 +304,17 @@ impl MetaApp { self.select_view(mod_idx, view, cx); } Action::SeedEntity { entity, next_view } => { + let errors = self.validate_required_fields(cx); + if !errors.is_empty() { + let n = errors.len(); + self.form_errors = errors; + self.toast = Some(SharedString::from(format!( + "faltan {n} campo(s) obligatorio(s)" + ))); + cx.notify(); + return; + } + self.form_errors.clear(); let was_editing = self.editing.is_some(); match self.commit_seed(mod_idx, &entity, cx) { Ok(outcome) => { @@ -298,6 +344,17 @@ impl MetaApp { params, next_view, } => { + let errors = self.validate_required_fields(cx); + if !errors.is_empty() { + let n = errors.len(); + self.form_errors = errors; + self.toast = Some(SharedString::from(format!( + "faltan {n} campo(s) obligatorio(s)" + ))); + cx.notify(); + return; + } + self.form_errors.clear(); match self.commit_morphism(mod_idx, &name, &inputs, ¶ms, cx) { Ok(outcome) => { let base = @@ -1680,7 +1737,7 @@ impl MetaApp { mut main: gpui::Div, fv: &FormView, mod_idx: usize, - _border: gpui::Hsla, + border: gpui::Hsla, text: gpui::Hsla, text_dim: gpui::Hsla, accent: gpui::Hsla, @@ -1690,6 +1747,7 @@ impl MetaApp { let submit_bg = theme.bg_button(); let submit_hover = theme.bg_button_hover(); let input_bg = theme.bg_input(); + let destructive = theme.accent_destructive(); // En modo edit, el título refleja eso para que el user no // se confunda creyendo que hace alta nueva. let title = match self.editing.as_ref() { @@ -1705,16 +1763,40 @@ impl MetaApp { .mb(px(12.)) .child(title), ); + let mut current_section: Option<&str> = None; for f in &fv.fields { + // Encabezado al cambiar de sección (campos consecutivos con + // la misma `section` se agrupan bajo un título). + if f.section.as_deref() != current_section { + if let Some(sec) = &f.section { + main = main.child( + div() + .mt(px(8.)) + .mb(px(4.)) + .pb(px(2.)) + .border_b_1() + .border_color(border) + .text_color(accent) + .text_size(px(12.)) + .child(sec.clone()), + ); + } + current_section = f.section.as_deref(); + } + let label = if f.required { format!("{} *", f.label) } else { f.label.clone() }; + // Si el campo tiene un error de validación, el label se + // resalta en color destructivo. + let has_error = self.form_errors.contains_key(&f.name); + let label_color = if has_error { destructive } else { text_dim }; let mut field_box = div().flex().flex_col().mb(px(10.)).child( div() - .text_color(text_dim) + .text_color(label_color) .text_size(px(11.)) .mb(px(2.)) .child(label), @@ -1810,6 +1892,16 @@ impl MetaApp { .child(help.clone()), ); } + // Error de validación inline, debajo del campo. + if let Some(err) = self.form_errors.get(&f.name) { + field_box = field_box.child( + div() + .mt(px(2.)) + .text_color(destructive) + .text_size(px(10.)) + .child(err.clone()), + ); + } main = main.child(field_box); } 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 19feb44..f345ae8 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 @@ -57,6 +57,7 @@ fn customers_module() -> Module { help: None, ref_entity: None, options: Vec::new(), + section: None, }], on_submit: Action::SeedEntity { entity: "Customer".into(), diff --git a/docs/changelog/nahual.md b/docs/changelog/nahual.md index eb5c859..51ecf94 100644 --- a/docs/changelog/nahual.md +++ b/docs/changelog/nahual.md @@ -2,6 +2,18 @@ Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19. +### feat(meta-form): pulido de formularios (Fase 7 del ERP nakui) + +Cierre del plan ERP. Pulido de la metainterfaz: + +- **Validación inline** — al fallar un submit por campos `required` + vacíos, el form ya no sólo muestra un toast genérico: marca cada + campo faltante (label en color destructivo + mensaje debajo). + `MetaApp.form_errors` + `validate_required_fields`. `AutoId` se + exime (se autogenera). +- **Secciones de formulario** — `FieldSpec.section` agrupa campos + consecutivos bajo un encabezado en el render del formulario. + ### feat(meta-form): export CSV de listas (Fase 6 del ERP nakui) - Toda vista de lista gana un botón «⬇ CSV» que exporta las filas diff --git a/docs/changelog/nakui.md b/docs/changelog/nakui.md index 94d9d7f..6ce75a3 100644 --- a/docs/changelog/nakui.md +++ b/docs/changelog/nakui.md @@ -2,6 +2,18 @@ ERP categórico. +### feat(nakui): Fase 7 del ERP — pulido (cierra el plan maestro) + +Última fase: el plan `docs/nakui-erp-masterplan.md` queda **completo +(7/7)**. Pulido de formularios — validación inline (campos obligatorios +marcados al fallar el submit) y secciones de formulario; el `abrir_form` +del CRM agrupa sus campos en «Oportunidad» e «Importe y fecha». Ver el +changelog de `nahual` (`FieldSpec.section`, `form_errors`). + +Nakui es ahora un ERP dirigido por datos completo: tablero → listas +(orden/filtro/paginación/export CSV) → fichas con relacionados → +formularios con captura sin fricción y validación inline. + ### feat(nakui): Fase 6 del ERP — export CSV de listas Sexta fase del plan maestro. Toda lista del ERP (clientes, diff --git a/docs/nakui-erp-masterplan.md b/docs/nakui-erp-masterplan.md index c7008d7..85c1c09 100644 --- a/docs/nakui-erp-masterplan.md +++ b/docs/nakui-erp-masterplan.md @@ -1,6 +1,8 @@ # Plan maestro — Nakui como ERP profesional -Estado: 2026-05-21 · Subproyecto: `nakui` + la metainterfaz `nahual` (meta-schema / meta-runtime / meta-form) + `nakui-ui`. +Estado: 2026-05-21 · **PLAN COMPLETADO — 7/7 fases.** Subproyecto: +`nakui` + la metainterfaz `nahual` (meta-schema / meta-runtime / +meta-form) + `nakui-ui`. ## 1 · Visión @@ -96,10 +98,16 @@ puro) → `meta-form` (render) → módulos de ejemplo + tests. - Pendiente menor (a futuro): impresión / export PDF (los temas `Print` de `nahual-theme` ya existen). -### Fase 7 · Pulido de producto +### Fase 7 · Pulido de producto — HECHA -- Validación inline (error por campo, resaltado de requeridos), - secciones de formulario, campos condicionales, pulido visual. +- Validación inline: al fallar un submit, los campos `required` vacíos + se marcan en color destructivo con un mensaje debajo del campo — no + sólo un toast genérico. +- Secciones de formulario: `FieldSpec.section` agrupa campos + consecutivos bajo un encabezado. +- Scope-out consciente: «campos condicionales» y el pulido puramente + visual quedan fuera — ningún módulo actual los necesita y el schema + los soporta agregar sin fricción si emergen. ### Fase 8 · Operación (futuro) diff --git a/examples/nakui-modules/crm/module.json b/examples/nakui-modules/crm/module.json index 692d0f7..7a252d0 100644 --- a/examples/nakui-modules/crm/module.json +++ b/examples/nakui-modules/crm/module.json @@ -139,12 +139,12 @@ "title": "Abrir oportunidad (morphism)", "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": "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" }, - { "name": "timestamp", "label": "Fecha ISO", "kind": "text", "required": true, "default": "2026-05-21T12:00:00Z" } + { "name": "cliente_ref", "label": "Cliente", "kind": "entity_ref", "ref_entity": "Cliente", "required": true, "section": "Oportunidad", "help": "Click en un cliente de la lista para seleccionarlo." }, + { "name": "oportunidad_id", "label": "ID de la oportunidad", "kind": "auto_id", "section": "Oportunidad" }, + { "name": "titulo", "label": "Título", "kind": "text", "required": true, "section": "Oportunidad" }, + { "name": "monto", "label": "Monto", "kind": "number", "required": true, "default": "0", "section": "Importe y fecha" }, + { "name": "currency", "label": "Moneda", "kind": "text", "default": "USD", "section": "Importe y fecha" }, + { "name": "timestamp", "label": "Fecha ISO", "kind": "text", "required": true, "default": "2026-05-21T12:00:00Z", "section": "Importe y fecha" } ], "on_submit": { "kind": "morphism",