diff --git a/crates/modules/nahual/libs/meta-runtime/src/format.rs b/crates/modules/nahual/libs/meta-runtime/src/format.rs index 0dac8ed..c8a7677 100644 --- a/crates/modules/nahual/libs/meta-runtime/src/format.rs +++ b/crates/modules/nahual/libs/meta-runtime/src/format.rs @@ -3,11 +3,38 @@ //! Sin GPUI: devuelven `String`s. El widget renderer los wrap-ea //! en `div().child(...)` o equivalente. +use std::cmp::Ordering; + use serde_json::Value; use uuid::Uuid; use nahual_meta_schema::ValueFormat; +/// Compara dos valores de celda para ordenar una lista. `None`/`null` +/// ordenan antes que cualquier valor. Números por valor numérico, +/// strings case-insensitive, bools `false < true`; tipos mixtos por su +/// forma string (orden estable, no semántico). +pub fn cmp_values(a: Option<&Value>, b: Option<&Value>) -> Ordering { + let nullish = |v: Option<&Value>| matches!(v, None | Some(Value::Null)); + match (nullish(a), nullish(b)) { + (true, true) => return Ordering::Equal, + (true, false) => return Ordering::Less, + (false, true) => return Ordering::Greater, + (false, false) => {} + } + match (a, b) { + (Some(Value::Number(x)), Some(Value::Number(y))) => x + .as_f64() + .partial_cmp(&y.as_f64()) + .unwrap_or(Ordering::Equal), + (Some(Value::String(x)), Some(Value::String(y))) => x.to_lowercase().cmp(&y.to_lowercase()), + (Some(Value::Bool(x)), Some(Value::Bool(y))) => x.cmp(y), + (Some(x), Some(y)) => x.to_string().cmp(&y.to_string()), + // Inalcanzable: el chequeo nullish de arriba cubre los None. + _ => Ordering::Equal, + } +} + /// Etiqueta humana para representar un record en el selector de /// EntityRef y en columnas de referencia. Heurística: prefiere campos /// de nombre comunes (ES + EN); fallback al UUID corto. @@ -208,6 +235,35 @@ mod tests { ); } + #[test] + fn cmp_values_orders_numbers_strings_nulls() { + // Números por valor, no lexicográfico. + assert_eq!( + cmp_values(Some(&json!(2)), Some(&json!(10))), + Ordering::Less + ); + // Strings case-insensitive. + assert_eq!( + cmp_values(Some(&json!("banana")), Some(&json!("Apple"))), + Ordering::Greater + ); + // null/None ordena primero. + assert_eq!(cmp_values(None, Some(&json!(1))), Ordering::Less); + assert_eq!( + cmp_values(Some(&Value::Null), Some(&json!("x"))), + Ordering::Less + ); + assert_eq!( + cmp_values(Some(&json!(5)), Some(&json!(5))), + Ordering::Equal + ); + // Bools. + assert_eq!( + cmp_values(Some(&json!(false)), Some(&json!(true))), + Ordering::Less + ); + } + #[test] fn format_value_non_number_falls_back_to_render_value() { assert_eq!( diff --git a/crates/modules/nahual/libs/meta-runtime/src/lib.rs b/crates/modules/nahual/libs/meta-runtime/src/lib.rs index fa24255..f8492fb 100644 --- a/crates/modules/nahual/libs/meta-runtime/src/lib.rs +++ b/crates/modules/nahual/libs/meta-runtime/src/lib.rs @@ -32,8 +32,8 @@ pub mod testing; pub use backend::{MetaBackend, WriteOutcome}; pub use delta::{compute_clear_fields, compute_field_delta}; pub use format::{ - format_value, human_label_for_record, preview_value, render_value, short_hash, short_uuid, - value_to_input_text, + cmp_values, format_value, human_label_for_record, preview_value, render_value, short_hash, + short_uuid, value_to_input_text, }; pub use parse::{infer_param_value, parse_field_value, resolve_param_value}; pub use refs::validate_entity_refs; diff --git a/crates/modules/nahual/widgets/meta-form/src/lib.rs b/crates/modules/nahual/widgets/meta-form/src/lib.rs index a9b8b90..1aea455 100644 --- a/crates/modules/nahual/widgets/meta-form/src/lib.rs +++ b/crates/modules/nahual/widgets/meta-form/src/lib.rs @@ -27,10 +27,11 @@ use gpui::{ }; use nahual_meta_runtime::{ - compute_clear_fields, compute_field_delta, format_value, human_label_for_record, + cmp_values, compute_clear_fields, compute_field_delta, format_value, human_label_for_record, parse_field_value, render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text, MetaBackend, WriteOutcome, }; + use nahual_meta_schema::{ Action, Column, DetailView, FieldKind, FieldSpec, FormView, ListView, Module, RelatedList, SelectOption, View, @@ -42,6 +43,9 @@ use nahual_widget_theme_switcher::theme_switcher; use serde_json::Value; use uuid::Uuid; +/// Filas por página en las vistas de lista. +const PAGE_SIZE: usize = 25; + /// Estado del runtime de UI. Toda la persistencia/ejecución está /// detrás del trait [`MetaBackend`]; este struct sólo conoce GPUI /// state y el schema de los módulos. @@ -75,6 +79,13 @@ pub struct MetaApp { /// Key de la vista a la que vuelve el botón «← Volver» de una /// ficha — la lista desde la que se abrió. detail_return: Option, + /// Estado de la vista de lista activa. Se reinician al navegar. + /// `list_search`: caja de búsqueda (sólo si la lista declara + /// `search_in`). `list_sort`: `(columna, ascendente)`. + /// `list_page`: página actual (0-based). + list_search: Option>, + list_sort: Option<(String, bool)>, + list_page: usize, /// Mensaje toast al pie (success de submit, error de carga, etc.). toast: Option, /// Si la carga de módulos falló al inicio. @@ -108,6 +119,9 @@ impl MetaApp { pending_delete: None, detail_target: None, detail_return: None, + list_search: None, + list_sort: None, + list_page: 0, toast: initial_toast.map(SharedString::from), load_error: initial_error.map(SharedString::from), } @@ -126,38 +140,56 @@ impl MetaApp { self.pending_delete = None; self.detail_target = None; self.form_inputs = BTreeMap::new(); + self.list_search = None; + self.list_sort = None; + self.list_page = 0; if let Some(module) = self.modules.get(mod_idx) { - if let Some(View::Form(form)) = module.views.get(&view_key) { - // Snapshot del record si estamos editando esta entity. - let editing_record: Option = self.editing.as_ref().and_then(|(e, id)| { - if e == &form.entity { - self.backend.load_record(e, *id) - } else { - None + match module.views.get(&view_key) { + Some(View::Form(form)) => { + // Snapshot del record si estamos editando esta entity. + let editing_record: Option = + self.editing.as_ref().and_then(|(e, id)| { + if e == &form.entity { + self.backend.load_record(e, *id) + } else { + None + } + }); + for f in &form.fields { + let initial = if f.kind == FieldKind::AutoId { + // Editando: conservar el id del record. + // Alta: UUID nuevo, que el usuario no teclea. + editing_record + .as_ref() + .and_then(|rec| rec.get(&f.name).map(value_to_input_text)) + .unwrap_or_else(|| Uuid::new_v4().to_string()) + } else if let Some(rec) = &editing_record { + rec.get(&f.name) + .map(value_to_input_text) + .unwrap_or_else(|| f.default.clone().unwrap_or_default()) + } else { + f.default.clone().unwrap_or_default() + }; + let input = cx.new(|cx| TextInput::new(initial, cx)); + self.form_inputs.insert(f.name.clone(), input); } - }); - for f in &form.fields { - let initial = if f.kind == FieldKind::AutoId { - // Editando: conservar el id del record. - // Alta: UUID nuevo, que el usuario no teclea. - editing_record - .as_ref() - .and_then(|rec| rec.get(&f.name).map(value_to_input_text)) - .unwrap_or_else(|| Uuid::new_v4().to_string()) - } else if let Some(rec) = &editing_record { - rec.get(&f.name) - .map(value_to_input_text) - .unwrap_or_else(|| f.default.clone().unwrap_or_default()) - } else { - f.default.clone().unwrap_or_default() - }; - let input = cx.new(|cx| TextInput::new(initial, cx)); - self.form_inputs.insert(f.name.clone(), input); } - } else { - // Cambiar a una view que no es Form invalida el editing - // pendiente. - self.editing = None; + Some(View::List(lv)) => { + // Caja de búsqueda viva si la lista declara search_in. + if !lv.search_in.is_empty() { + let input = cx.new(|cx| TextInput::new("", cx).with_placeholder("buscar…")); + // Re-render del widget cuando el input cambia → + // filtrado en vivo mientras se teclea. + cx.observe(&input, |_this, _, cx| cx.notify()).detach(); + self.list_search = Some(input); + } + self.editing = None; + } + _ => { + // Cambiar a una view que no es Form invalida el + // editing pendiente. + self.editing = None; + } } } cx.notify(); @@ -178,10 +210,21 @@ impl MetaApp { self.editing = None; self.pending_delete = None; self.form_inputs = BTreeMap::new(); + self.list_search = None; + self.list_sort = None; + self.list_page = 0; self.toast = None; cx.notify(); } + /// 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.list_sort = next_sort(self.list_sort.take(), field); + self.list_page = 0; + cx.notify(); + } + /// Inicia un edit del record: setea `editing` y abre la primera /// view de tipo Form del módulo (convención: la del schema). fn open_edit(&mut self, mod_idx: usize, entity: String, id: Uuid, cx: &mut Context) { @@ -457,6 +500,17 @@ fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> { Some(cur) } +/// Próximo estado de orden al hacer clic en el header `field`: la misma +/// columna cicla ascendente → descendente → sin orden; otra columna +/// arranca ascendente. +fn next_sort(current: Option<(String, bool)>, field: &str) -> Option<(String, bool)> { + match current { + Some((f, true)) if f == field => Some((f, false)), + Some((f, false)) if f == field => None, + _ => Some((field.to_string(), true)), + } +} + impl Render for MetaApp { fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { // Paleta del chrome viene del Theme global. Derivamos los @@ -842,10 +896,55 @@ impl MetaApp { } main = main.child(header); - let rows = self.list_rows(&lv.entity); - let total = rows.len(); + // Caja de búsqueda (sólo si la lista declara `search_in`). + if let Some(search) = &self.list_search { + main = main.child( + div() + .mb(px(8.)) + .flex() + .flex_row() + .items_center() + .gap(px(6.)) + .child(div().text_color(text_dim).text_size(px(12.)).child("🔍")) + .child(search.clone()), + ); + } + let row_detail = lv.row_detail.clone(); + // Filas: buscar → ordenar → paginar. + let mut all_rows = self.list_rows(&lv.entity); + let query = self + .list_search + .as_ref() + .map(|i| i.read(cx).text().trim().to_lowercase()) + .unwrap_or_default(); + if !query.is_empty() { + all_rows.retain(|(_, v)| { + lv.search_in.iter().any(|field| { + lookup_field(v, field) + .map(|cell| render_value(Some(cell)).to_lowercase().contains(&query)) + .unwrap_or(false) + }) + }); + } + if let Some((field, asc)) = &self.list_sort { + all_rows.sort_by(|(_, a), (_, b)| { + let ord = cmp_values(lookup_field(a, field), lookup_field(b, field)); + if *asc { + ord + } else { + ord.reverse() + } + }); + } + let total = all_rows.len(); + let page_count = total.div_ceil(PAGE_SIZE).max(1); + let page = self.list_page.min(page_count - 1); + let start = page * PAGE_SIZE; + let end = (start + PAGE_SIZE).min(total); + let rows: Vec<(Uuid, Value)> = all_rows[start..end].to_vec(); + let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::().max(0.01); let mut col_header = div() .flex() @@ -857,11 +956,28 @@ impl MetaApp { .text_size(px(11.)); for c in &lv.columns { let frac = c.weight / total_weight; + // Indicador del orden activo + clic en el header para ordenar. + let arrow = match &self.list_sort { + Some((f, asc)) if f == &c.field => { + if *asc { + " ▲" + } else { + " ▼" + } + } + _ => "", + }; + let field = c.field.clone(); col_header = col_header.child( div() + .id(SharedString::from(format!("col-{mod_idx}-{}", c.field))) .flex_grow() .flex_basis(px(100. * frac)) - .child(c.label.clone()), + .hover(move |d| d.bg(action_hover)) + .child(format!("{}{arrow}", c.label)) + .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { + this.toggle_sort(&field, cx); + })), ); } col_header = col_header @@ -952,22 +1068,64 @@ impl MetaApp { main = main.child(row); } - if rows.is_empty() { + if total == 0 { + let msg = if query.is_empty() { + format!("(sin {})", lv.entity) + } else { + "(sin resultados para la búsqueda)".to_string() + }; main = main.child( div() .py(px(12.)) .text_color(text_dim) .text_size(px(12.)) - .child(format!("(sin {})", lv.entity)), + .child(msg), ); } else { - main = main.child( - div() - .mt(px(8.)) - .text_color(text_dim) - .text_size(px(11.)) - .child(format!("{total} fila(s)")), - ); + let mut footer = div() + .mt(px(8.)) + .flex() + .flex_row() + .items_center() + .gap(px(8.)) + .text_color(text_dim) + .text_size(px(11.)); + if page_count > 1 { + let last_page = page_count - 1; + footer = footer.child( + div() + .id("list-prev") + .px(px(8.)) + .py(px(2.)) + .bg(action_bg) + .rounded(px(3.)) + .text_color(if page == 0 { text_dim } else { accent }) + .hover(move |d| d.bg(action_hover)) + .child("◀") + .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { + this.list_page = this.list_page.saturating_sub(1); + cx.notify(); + })), + ); + footer = footer.child(div().child(format!("página {}/{}", page + 1, page_count))); + footer = footer.child( + div() + .id("list-next") + .px(px(8.)) + .py(px(2.)) + .bg(action_bg) + .rounded(px(3.)) + .text_color(if page >= last_page { text_dim } else { accent }) + .hover(move |d| d.bg(action_hover)) + .child("▶") + .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { + this.list_page = (this.list_page + 1).min(last_page); + cx.notify(); + })), + ); + } + footer = footer.child(div().child(format!("{total} fila(s)"))); + main = main.child(footer); } main @@ -1566,6 +1724,22 @@ mod tests { assert!(lookup_field(&v, "address.zipcode").is_none()); } + #[test] + fn next_sort_cycles_asc_desc_off() { + // Sin orden → ascendente. + let s = next_sort(None, "monto"); + assert_eq!(s, Some(("monto".to_string(), true))); + // Misma columna → descendente. + let s = next_sort(s, "monto"); + assert_eq!(s, Some(("monto".to_string(), false))); + // Misma columna otra vez → sin orden. + let s = next_sort(s, "monto"); + assert_eq!(s, None); + // Otra columna siempre arranca ascendente. + let s = next_sort(Some(("monto".to_string(), false)), "etapa"); + assert_eq!(s, Some(("etapa".to_string(), true))); + } + #[test] fn append_compact_msg_handles_both_branches() { // Sin compact: base solo, sin separador. diff --git a/docs/changelog/nahual.md b/docs/changelog/nahual.md index b57f60d..76bf4bd 100644 --- a/docs/changelog/nahual.md +++ b/docs/changelog/nahual.md @@ -2,6 +2,23 @@ Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19. +### feat(meta-form): listas profesionales — orden, búsqueda, paginación + +Fase 4 del ERP nakui. Las vistas de lista de `meta-form` ganan: + +- **Orden por columna** — clic en un header cicla ascendente → + descendente → sin orden (indicador ▲/▼). Comparación de valores con + `cmp_values` (nuevo en `meta-runtime`): números por valor, strings + case-insensitive, `null` primero. +- **Búsqueda en vivo** — caja 🔍 que filtra por substring contra las + columnas de `search_in` mientras se teclea (vía `cx.observe` del + `TextInput`). `search_in` ya existía en el schema; ahora se renderiza. +- **Paginación** — 25 filas por página, controles ◀ ▶ y «página N/M». + +Sin cambios de schema: orden y página son estado del widget, se +reinician al navegar. Helpers puros `cmp_values` y `next_sort` con +tests. + ### feat(meta-*): ficha de detalle (Fase 3 del ERP nakui) La metainterfaz gana una tercera clase de vista: diff --git a/docs/nakui-erp-masterplan.md b/docs/nakui-erp-masterplan.md index 70c65f9..8f81350 100644 --- a/docs/nakui-erp-masterplan.md +++ b/docs/nakui-erp-masterplan.md @@ -69,11 +69,15 @@ puro) → `meta-form` (render) → módulos de ejemplo + tests. back-references por `via_field`. - **Resultado**: navegación de ERP — lista → ficha → relacionados. -### Fase 4 · Listas profesionales +### Fase 4 · Listas profesionales — HECHA -- Orden por columna (clic en header), filtros por columna, paginación. -- Columnas computadas / agregadas. +- Orden por columna: clic en el header cicla ascendente → descendente → + sin orden, con indicador ▲/▼. +- Búsqueda en vivo: caja que filtra por substring contra las columnas + de `search_in` mientras se teclea. +- Paginación: 25 filas por página, controles ◀ ▶ y «página N/M». - **Resultado**: listas usables con cientos/miles de registros. +- Pendiente menor (a futuro): filtros por columna, columnas computadas. ### Fase 5 · Tablero y KPIs