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 · por sergio · 2026-05-21T19:20:15Z (hace 5 meses) · padre 6588d0ed6 · ver el árbol en este commit
Delta semántico · 11
crates/modules/nahual/libs/meta-runtime/src/format.rs
crates/modules/nahual/widgets/meta-form/src/lib.rs
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