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:
@@ -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 {
|
||||
|
||||
@@ -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, ¶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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user