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>
This commit is contained in:
sergio
2026-05-21 19:43:44 +00:00
parent b13486e240
commit c56ef25546
9 changed files with 168 additions and 12 deletions
@@ -122,6 +122,7 @@ mod tests {
help: None,
ref_entity: None,
options: Vec::new(),
section: None,
}
}
@@ -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(),
@@ -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, &params, 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);
}
@@ -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(),