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>
This commit is contained in:
sergio
2026-05-21 19:20:15 +00:00
parent 6588d0ed6c
commit ab1cf9998a
5 changed files with 298 additions and 47 deletions
@@ -3,11 +3,38 @@
//! Sin GPUI: devuelven `String`s. El widget renderer los wrap-ea //! Sin GPUI: devuelven `String`s. El widget renderer los wrap-ea
//! en `div().child(...)` o equivalente. //! en `div().child(...)` o equivalente.
use std::cmp::Ordering;
use serde_json::Value; use serde_json::Value;
use uuid::Uuid; use uuid::Uuid;
use nahual_meta_schema::ValueFormat; 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 /// Etiqueta humana para representar un record en el selector de
/// EntityRef y en columnas de referencia. Heurística: prefiere campos /// EntityRef y en columnas de referencia. Heurística: prefiere campos
/// de nombre comunes (ES + EN); fallback al UUID corto. /// 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] #[test]
fn format_value_non_number_falls_back_to_render_value() { fn format_value_non_number_falls_back_to_render_value() {
assert_eq!( assert_eq!(
@@ -32,8 +32,8 @@ pub mod testing;
pub use backend::{MetaBackend, WriteOutcome}; pub use backend::{MetaBackend, WriteOutcome};
pub use delta::{compute_clear_fields, compute_field_delta}; pub use delta::{compute_clear_fields, compute_field_delta};
pub use format::{ pub use format::{
format_value, human_label_for_record, preview_value, render_value, short_hash, short_uuid, cmp_values, format_value, human_label_for_record, preview_value, render_value, short_hash,
value_to_input_text, short_uuid, value_to_input_text,
}; };
pub use parse::{infer_param_value, parse_field_value, resolve_param_value}; pub use parse::{infer_param_value, parse_field_value, resolve_param_value};
pub use refs::validate_entity_refs; pub use refs::validate_entity_refs;
@@ -27,10 +27,11 @@ use gpui::{
}; };
use nahual_meta_runtime::{ 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, parse_field_value, render_value, resolve_param_value, short_uuid, validate_entity_refs,
value_to_input_text, MetaBackend, WriteOutcome, value_to_input_text, MetaBackend, WriteOutcome,
}; };
use nahual_meta_schema::{ use nahual_meta_schema::{
Action, Column, DetailView, FieldKind, FieldSpec, FormView, ListView, Module, RelatedList, Action, Column, DetailView, FieldKind, FieldSpec, FormView, ListView, Module, RelatedList,
SelectOption, View, SelectOption, View,
@@ -42,6 +43,9 @@ use nahual_widget_theme_switcher::theme_switcher;
use serde_json::Value; use serde_json::Value;
use uuid::Uuid; 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á /// Estado del runtime de UI. Toda la persistencia/ejecución está
/// detrás del trait [`MetaBackend`]; este struct sólo conoce GPUI /// detrás del trait [`MetaBackend`]; este struct sólo conoce GPUI
/// state y el schema de los módulos. /// 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 /// Key de la vista a la que vuelve el botón «← Volver» de una
/// ficha — la lista desde la que se abrió. /// ficha — la lista desde la que se abrió.
detail_return: Option<String>, 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.). /// Mensaje toast al pie (success de submit, error de carga, etc.).
toast: Option<SharedString>, toast: Option<SharedString>,
/// Si la carga de módulos falló al inicio. /// Si la carga de módulos falló al inicio.
@@ -108,6 +119,9 @@ impl<B: MetaBackend> MetaApp<B> {
pending_delete: None, pending_delete: None,
detail_target: None, detail_target: None,
detail_return: None, detail_return: None,
list_search: None,
list_sort: None,
list_page: 0,
toast: initial_toast.map(SharedString::from), toast: initial_toast.map(SharedString::from),
load_error: initial_error.map(SharedString::from), load_error: initial_error.map(SharedString::from),
} }
@@ -126,10 +140,15 @@ impl<B: MetaBackend> MetaApp<B> {
self.pending_delete = None; self.pending_delete = None;
self.detail_target = None; self.detail_target = None;
self.form_inputs = BTreeMap::new(); 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(module) = self.modules.get(mod_idx) {
if let Some(View::Form(form)) = module.views.get(&view_key) { match module.views.get(&view_key) {
Some(View::Form(form)) => {
// Snapshot del record si estamos editando esta entity. // Snapshot del record si estamos editando esta entity.
let editing_record: Option<Value> = self.editing.as_ref().and_then(|(e, id)| { let editing_record: Option<Value> =
self.editing.as_ref().and_then(|(e, id)| {
if e == &form.entity { if e == &form.entity {
self.backend.load_record(e, *id) self.backend.load_record(e, *id)
} else { } else {
@@ -154,11 +173,24 @@ impl<B: MetaBackend> MetaApp<B> {
let input = cx.new(|cx| TextInput::new(initial, cx)); let input = cx.new(|cx| TextInput::new(initial, cx));
self.form_inputs.insert(f.name.clone(), input); self.form_inputs.insert(f.name.clone(), input);
} }
} else { }
// Cambiar a una view que no es Form invalida el editing Some(View::List(lv)) => {
// pendiente. // 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; self.editing = None;
} }
_ => {
// Cambiar a una view que no es Form invalida el
// editing pendiente.
self.editing = None;
}
}
} }
cx.notify(); cx.notify();
} }
@@ -178,10 +210,21 @@ impl<B: MetaBackend> MetaApp<B> {
self.editing = None; self.editing = None;
self.pending_delete = None; self.pending_delete = None;
self.form_inputs = BTreeMap::new(); self.form_inputs = BTreeMap::new();
self.list_search = None;
self.list_sort = None;
self.list_page = 0;
self.toast = None; self.toast = None;
cx.notify(); 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 /// Inicia un edit del record: setea `editing` y abre la primera
/// view de tipo Form del módulo (convención: la del schema). /// 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>) { 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) 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> { impl<B: MetaBackend> Render for MetaApp<B> {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Paleta del chrome viene del Theme global. Derivamos los // Paleta del chrome viene del Theme global. Derivamos los
@@ -842,10 +896,55 @@ impl<B: MetaBackend> MetaApp<B> {
} }
main = main.child(header); main = main.child(header);
let rows = self.list_rows(&lv.entity); // Caja de búsqueda (sólo si la lista declara `search_in`).
let total = rows.len(); 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(); 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 total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
let mut col_header = div() let mut col_header = div()
.flex() .flex()
@@ -857,11 +956,28 @@ impl<B: MetaBackend> MetaApp<B> {
.text_size(px(11.)); .text_size(px(11.));
for c in &lv.columns { for c in &lv.columns {
let frac = c.weight / total_weight; 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( col_header = col_header.child(
div() div()
.id(SharedString::from(format!("col-{mod_idx}-{}", c.field)))
.flex_grow() .flex_grow()
.flex_basis(px(100. * frac)) .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 col_header = col_header
@@ -952,22 +1068,64 @@ impl<B: MetaBackend> MetaApp<B> {
main = main.child(row); 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( main = main.child(
div() div()
.py(px(12.)) .py(px(12.))
.text_color(text_dim) .text_color(text_dim)
.text_size(px(12.)) .text_size(px(12.))
.child(format!("(sin {})", lv.entity)), .child(msg),
); );
} else { } else {
main = main.child( let mut footer = div()
div()
.mt(px(8.)) .mt(px(8.))
.flex()
.flex_row()
.items_center()
.gap(px(8.))
.text_color(text_dim) .text_color(text_dim)
.text_size(px(11.)) .text_size(px(11.));
.child(format!("{total} fila(s)")), 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 main
@@ -1566,6 +1724,22 @@ mod tests {
assert!(lookup_field(&v, "address.zipcode").is_none()); 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] #[test]
fn append_compact_msg_handles_both_branches() { fn append_compact_msg_handles_both_branches() {
// Sin compact: base solo, sin separador. // Sin compact: base solo, sin separador.
+17
View File
@@ -2,6 +2,23 @@
Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19. 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) ### feat(meta-*): ficha de detalle (Fase 3 del ERP nakui)
La metainterfaz gana una tercera clase de vista: La metainterfaz gana una tercera clase de vista:
+7 -3
View File
@@ -69,11 +69,15 @@ puro) → `meta-form` (render) → módulos de ejemplo + tests.
back-references por `via_field`. back-references por `via_field`.
- **Resultado**: navegación de ERP — lista → ficha → relacionados. - **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. - Orden por columna: clic en el header cicla ascendente → descendente →
- Columnas computadas / agregadas. 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. - **Resultado**: listas usables con cientos/miles de registros.
- Pendiente menor (a futuro): filtros por columna, columnas computadas.
### Fase 5 · Tablero y KPIs ### Fase 5 · Tablero y KPIs