feat(nakui): Fase 7 del ERP — pulido (cierra el plan maestro)
Validación inline: al fallar un submit por campos required vacíos, el
form los marca (label destructivo + mensaje debajo), no sólo un toast.
MetaApp.form_errors + validate_required_fields. Secciones de formulario:
FieldSpec.section agrupa campos bajo encabezados; abrir_form del CRM las
usa. Campos condicionales y pulido puramente visual: scope-out conciente.
El plan docs/nakui-erp-masterplan.md queda completo (7/7 fases). Tests
verdes (meta-schema 16, meta-runtime 70, meta-form 8, nakui-ui 14);
clippy limpio en las libs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
c56ef2554615e03b5fcdfb80c0e5e5c826a94f00 · por sergio · 2026-05-21T19:43:44Z (hace 5 meses) · padre b13486e24 · ver el árbol en este commit
Delta semántico · 16
crates/apps/nakui-ui/src/main.rs
crates/modules/nahual/libs/meta-runtime/src/parse.rs
cambió por dentro fn spec
crates/modules/nahual/libs/meta-schema/src/lib.rs
crates/modules/nahual/widgets/meta-form/src/lib.rs
crates/modules/nahual/widgets/meta-form/tests/widget_with_mock_backend.rs
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<SelectOption>,
+ /// 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<String>,
}
#[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<B: MetaBackend> {
list_search: Option<Entity<TextInput>>,
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<String, String>,
/// Mensaje toast al pie (success de submit, error de carga, etc.).
toast: Option<SharedString>,
/// Si la carga de módulos falló al inicio.
@@ -121,6 +125,7 @@ impl<B: MetaBackend> MetaApp<B> {
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<B: MetaBackend> MetaApp<B> {
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<B: MetaBackend> MetaApp<B> {
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<B: MetaBackend> MetaApp<B> {
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<Self>) -> BTreeMap<String, String> {
+ 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<Self>) {
@@ -269,6 +304,17 @@ impl<B: MetaBackend> MetaApp<B> {
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<B: MetaBackend> MetaApp<B> {
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<B: MetaBackend> MetaApp<B> {
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<B: MetaBackend> MetaApp<B> {
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<B: MetaBackend> MetaApp<B> {
.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<B: MetaBackend> MetaApp<B> {
.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",