tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Sutichaykuna

feat(nakui): Fase 4 del ERP — listas profesionales (orden/búsqueda/página)

Las vistas de lista de meta-form ganan: orden por columna (clic en
header cicla asc→desc→off con indicador ▲/▼), búsqueda en vivo (caja 🔍
que filtra por search_in mientras se teclea, vía cx.observe del
TextInput) y paginación (25/página, controles ◀▶). Sin cambios de
schema: son estado del widget. Helpers puros cmp_values (meta-runtime)
y next_sort con tests.

Tests verdes (meta-runtime 63, meta-form 8); clippy limpio.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

ab1cf9998af4dbb23172faee0d6070ed499317e0 · ruwaq sergio · (5 killa ñawpaq) · tayta 6588d0ed6 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 11

crates/modules/nahual/libs/meta-runtime/src/format.rs

crates/modules/nahual/widgets/meta-form/src/lib.rs

5 qillqakuna · +298 −47

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<B: MetaBackend> {
     /// 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<String>,
+    /// 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<Entity<TextInput>>,
+    list_sort: Option<(String, bool)>,
+    list_page: usize,
     /// Mensaje toast al pie (success de submit, error de carga, etc.).
     toast: Option<SharedString>,
     /// Si la carga de módulos falló al inicio.
@@ -108,6 +119,9 @@ impl<B: MetaBackend> MetaApp<B> {
             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<B: MetaBackend> MetaApp<B> {
         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<Value> = 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<Value> =
+                        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<B: MetaBackend> MetaApp<B> {
         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>) {
+        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<Self>) {
@@ -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<B: MetaBackend> Render for MetaApp<B> {
     fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         // Paleta del chrome viene del Theme global. Derivamos los
@@ -842,10 +896,55 @@ impl<B: MetaBackend> MetaApp<B> {
         }
         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::<f32>().max(0.01);
         let mut col_header = div()
             .flex()
@@ -857,11 +956,28 @@ impl<B: MetaBackend> MetaApp<B> {
             .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<B: MetaBackend> MetaApp<B> {
             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