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:
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,10 +140,15 @@ 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) {
|
||||
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)| {
|
||||
let editing_record: Option<Value> =
|
||||
self.editing.as_ref().and_then(|(e, id)| {
|
||||
if e == &form.entity {
|
||||
self.backend.load_record(e, *id)
|
||||
} else {
|
||||
@@ -154,11 +173,24 @@ impl<B: MetaBackend> MetaApp<B> {
|
||||
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.
|
||||
}
|
||||
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()
|
||||
let mut footer = div()
|
||||
.mt(px(8.))
|
||||
.flex()
|
||||
.flex_row()
|
||||
.items_center()
|
||||
.gap(px(8.))
|
||||
.text_color(text_dim)
|
||||
.text_size(px(11.))
|
||||
.child(format!("{total} fila(s)")),
|
||||
.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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user