//! `nahual-widget-meta-form` — widget GPUI de metainterfaz. //! //! Renderea cualquier conjunto de [`nahual_meta_schema::Module`] sobre //! cualquier impl de [`nahual_meta_runtime::MetaBackend`]: sidebar con //! menús, list/form views, modal de delete, EntityRef selector. //! //! El widget es **app-agnostic**: no asume Nakui, ni storage //! particular, ni lifecycle de bootstrapping. El binario que lo //! consume: //! //! 1. Carga sus módulos (típicamente via `brahman-cards`). //! 2. Construye su backend concreto (typicamente wireado a un //! store/log/executor de su stack). //! 3. Crea `MetaApp::new(modules, backend, initial_toast, //! initial_error, cx)` y lo monta como root view de GPUI. //! //! Para una metainterfaz Nakui-completa ver `nakui-ui` (binario //! shell que provee `NakuiBackend`). #![forbid(unsafe_code)] use std::collections::BTreeMap; use gpui::{ div, prelude::*, px, ClickEvent, Context, Entity, IntoElement, KeyDownEvent, Render, SharedString, Window, }; use nahual_meta_runtime::{ cmp_values, compute_clear_fields, compute_field_delta, compute_metric, format_value, human_label_for_record, parse_field_value, render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text, MetaBackend, MetricResult, WriteOutcome, }; use nahual_meta_schema::{ Action, Column, DashboardView, DetailView, FieldKind, FieldSpec, FormView, ListView, Module, RelatedList, SelectOption, View, }; use nahual_theme::Theme; use nahual_widget_banner::{banner_themed, themed_colors, Banner}; use nahual_widget_text_input::TextInput; 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. /// /// Genérico sobre el backend `B`. Un binario decide qué backend /// proveer (Nakui via `NakuiBackend`, mock para tests, etc.). pub struct MetaApp { /// Módulos cargados, ordenados por id. modules: Vec, /// Backend que ejecuta seed/update/delete/morphism. backend: B, /// (módulo idx, vista key) actualmente activos. active: Option<(usize, String)>, /// Inputs vivos para el form actual: nombre del campo → TextInput. /// Se reemplaza al cambiar de vista (drop de los anteriores). form_inputs: BTreeMap>, /// Si está set, el próximo render del Form pre-llena los inputs /// con los valores del record indicado, y `commit_seed` emite /// un `update` (no un seed nuevo). Limpia al cambiar de view o /// tras submit exitoso. editing: Option<(String, Uuid)>, /// Si está set, el banner modal de confirmación de delete está /// activo: `(entity, id)` del record que el usuario marcó para /// borrar. Permanece hasta que el usuario click [Confirmar] /// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia). /// Navegación a otra view también cancela. pending_delete: Option<(String, Uuid)>, /// Si la vista activa es una `Detail`, el id del record que se /// muestra. Lo setea [`Self::select_detail`]. detail_target: Option, /// 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. load_error: Option, } impl MetaApp { /// Constructor del widget. El caller pre-construye sus módulos + /// backend + cualquier mensaje inicial que quiera mostrar al /// usuario al abrir (típicamente el toast del replay y el error /// banner de cosas que fallaron de bootstrap). /// /// La active view default es la primera entry del menú del /// primer módulo (orden lexicográfico por id). pub fn new( modules: Vec, backend: B, initial_toast: Option, initial_error: Option, _cx: &mut Context, ) -> Self { let active = modules .first() .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone()))); Self { modules, backend, active, form_inputs: BTreeMap::new(), editing: None, 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), } } /// Cambia la vista activa. Si la nueva vista es un Form, crea /// `TextInput` entities para cada field. Pre-llena con valores /// del record si hay `editing` para esa entity; si no, usa el /// `default` del schema. /// Drop de los inputs anteriores ocurre al sobreescribir el map. fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context) { self.active = Some((mod_idx, view_key.clone())); self.toast = None; // Navegar a otra view cancela cualquier delete pendiente: // el record marcado puede no estar visible en la nueva view. 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) { 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); } } 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(); } /// Abre la ficha de detalle `detail_view` para el record `id`. /// Recuerda la vista actual para el botón «← Volver». fn select_detail( &mut self, mod_idx: usize, detail_view: String, id: Uuid, cx: &mut Context, ) { self.detail_return = self.active.as_ref().map(|(_, v)| v.clone()); self.active = Some((mod_idx, detail_view)); self.detail_target = Some(id); 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) { self.editing = Some((entity.clone(), id)); let form_view_key = self.modules.get(mod_idx).and_then(|m| { m.views.iter().find_map(|(key, v)| match v { View::Form(form) if form.entity == entity => Some(key.clone()), _ => None, }) }); match form_view_key { Some(key) => self.select_view(mod_idx, key, cx), None => { self.toast = Some(SharedString::from(format!( "no hay form view para entity '{entity}' en este módulo" ))); self.editing = None; cx.notify(); } } } /// Borra un record. Emite Morphism con un FieldOp::Delete + lo /// aplica al store via `apply` (no via remove directo, mantiene /// el modelo de "todo cambio post-seed pasa por ops"). /// Borra un record vía `MetaBackend::delete`. Devuelve el outcome /// del backend (incluye eventual `post_status` del compact tick). fn commit_delete(&mut self, entity: &str, id: Uuid) -> Result { self.backend.delete(entity, id) } /// Aplica una acción (click en menú, botón de form, action de /// list). Mutaciones contra el backend ocurren acá. pub fn apply_action(&mut self, action: Action, cx: &mut Context) { let mod_idx = match self.active.as_ref() { Some((i, _)) => *i, None => return, }; match action { Action::OpenView { view, .. } => { // Salir a otra view cancela el edit pendiente. self.editing = None; self.select_view(mod_idx, view, cx); } Action::SeedEntity { entity, next_view } => { let was_editing = self.editing.is_some(); match self.commit_seed(mod_idx, &entity, cx) { Ok(outcome) => { let toast_msg = format_seed_toast(&entity, was_editing, &outcome); self.toast = Some(append_compact_msg(toast_msg, outcome.post_status)); // Limpia editing tras un commit exitoso — // el record ya está sincronizado (incluso // un NoChange cierra el modo edit). self.editing = None; if let Some(v) = next_view { self.select_view(mod_idx, v, cx); } else { for input in self.form_inputs.values() { input.update(cx, |inp, cx| inp.set_text("", cx)); } } } Err(e) => { self.toast = Some(SharedString::from(format!("error: {e}"))); } } cx.notify(); } Action::Morphism { name, inputs, params, next_view, } => { match self.commit_morphism(mod_idx, &name, &inputs, ¶ms, cx) { Ok(outcome) => { let base = format!("morphism '{name}' OK ({} op(s) aplicadas)", outcome.changed); self.toast = Some(append_compact_msg(base, outcome.post_status)); if let Some(v) = next_view { self.select_view(mod_idx, v, cx); } } Err(e) => { self.toast = Some(SharedString::from(format!("morphism '{name}' falló: {e}"))); } } cx.notify(); } } } /// Despacha un morphism via el backend. Resuelve inputs (UUIDs) /// + params (Value object) leyendo los TextInput del form. fn commit_morphism( &mut self, mod_idx: usize, morphism: &str, inputs_map: &BTreeMap, params_fields: &[String], cx: &mut Context, ) -> Result { let module = self .modules .get(mod_idx) .ok_or_else(|| "módulo inválido".to_string())?; let module_id = module.id.clone(); // Resolver inputs: por cada (role, field_name), parsear el // value del input como Uuid. let mut inputs: BTreeMap = BTreeMap::new(); for (role, field_name) in inputs_map { let raw = self .form_inputs .get(field_name) .map(|inp| inp.read(&*cx).text().to_string()) .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?; let id = Uuid::parse_str(raw.trim()).map_err(|_| { format!("input '{role}' (field '{field_name}'): '{raw}' no es UUID válido") })?; inputs.insert(role.clone(), id); } // Resolver params: si la lista está vacía, todos los fields // del form que no estén en `inputs_map` van a params. let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect(); let mut params_obj = serde_json::Map::new(); let field_iter: Vec = if params_fields.is_empty() { self.form_inputs .keys() .filter(|k| !input_field_set.contains(*k)) .cloned() .collect() } else { params_fields.to_vec() }; // FieldSpec del Form view activo para parseo estricto por kind. let active_form_fields: Option> = self.active.as_ref().and_then(|(_, vk)| { module.views.get(vk).and_then(|v| match v { View::Form(f) => Some(f.fields.clone()), _ => None, }) }); for field_name in field_iter { let raw = self .form_inputs .get(&field_name) .map(|inp| inp.read(&*cx).text().to_string()) .unwrap_or_default(); let spec = active_form_fields .as_ref() .and_then(|fs| fs.iter().find(|f| f.name == field_name)); let value = resolve_param_value(&field_name, &raw, spec)?; params_obj.insert(field_name, value); } self.backend .morphism(&module_id, morphism, inputs, Value::Object(params_obj)) } /// Construye un payload desde los TextInput vivos y delega al /// backend (`seed` para alta nueva, `update` con set+clear para /// edit). Devuelve el `WriteOutcome` del backend (incluye /// `changed` y `post_status` para el toast). fn commit_seed( &mut self, mod_idx: usize, entity: &str, cx: &mut Context, ) -> Result { let module = &self.modules[mod_idx]; let spec_fields: Vec = match self.active.as_ref() { Some((_, view_key)) => match module.views.get(view_key) { Some(View::Form(f)) => f.fields.clone(), _ => return Err("la vista activa no es un Form".into()), }, None => return Err("ninguna vista activa".into()), }; let mut obj = serde_json::Map::new(); // Fields que el form deja vacíos y son optional: candidatos // a Clear en el path de EDIT. let mut to_clear: Vec = Vec::new(); // EntityRef refs a validar tras parse loop (en una toma del // backend en lugar de N). let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new(); for f in &spec_fields { let raw = self .form_inputs .get(&f.name) .map(|input| input.read(cx).text().to_string()) .unwrap_or_default(); if f.required && raw.trim().is_empty() { return Err(format!("campo '{}' es obligatorio", f.label)); } if raw.is_empty() && !f.required { to_clear.push(f.name.clone()); continue; } let value = parse_field_value(f.kind, &raw).map_err(|e| format!("campo '{}': {e}", f.label))?; if f.kind == FieldKind::EntityRef { if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) { let id = Uuid::parse_str(uuid_str).expect("parse_field_value validated UUID"); entity_refs.push((f.label.clone(), target.clone(), id)); } } obj.insert(f.name.clone(), value); } // Validar EntityRefs contra el backend actual. Cierre wrappea // backend.load_record para mantener la firma de // validate_entity_refs (que es store-agnóstica). if !entity_refs.is_empty() { let backend = &self.backend; validate_entity_refs(|e, id| backend.load_record(e, id), &entity_refs)?; } // Ramificación: edit (hay editing para esta entity) vs alta. let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned(); if let Some((_, id)) = editing_match { // EDIT: cargar current, computar delta, llamar // backend.update con set+clear pre-computados. let current = self.backend.load_record(entity, id).unwrap_or(Value::Null); let set_delta = compute_field_delta(¤t, &obj); let clear_fields = compute_clear_fields(¤t, &to_clear); self.backend.update(entity, id, set_delta, clear_fields) } else { // SEED: alta nueva — el backend genera el Uuid. self.backend.seed(entity, obj) } } /// Snapshot ordenado de records de una entity (proxy al backend). fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> { self.backend.list_records(entity) } } /// Formatea el toast para la rama Action::SeedEntity según el /// `WriteOutcome` del backend. `was_editing` distingue "creado" /// vs "actualizado" — el WriteOutcome solo no alcanza porque /// `seed` y `update` ambos devuelven `id = Some(...)`. fn format_seed_toast(entity: &str, was_editing: bool, outcome: &WriteOutcome) -> String { let id_short = outcome.id.map(|id| short_uuid(&id)).unwrap_or_default(); match (was_editing, outcome.changed) { (false, _) => format!("creado {entity} {id_short}"), (true, 0) => format!("{entity} {id_short} sin cambios — no log entry"), (true, n) => format!("actualizado {entity} {id_short} ({n} campo(s))"), } } /// Concatena el msg opcional del compact tick al toast del op /// original con `"; "` separator. fn append_compact_msg(base: String, compact_msg: Option) -> SharedString { match compact_msg { Some(m) => SharedString::from(format!("{base}; {m}")), None => SharedString::from(base), } } /// Walker dentro de un `Value` por path con `.` como separador. /// Local porque sólo lo usa la lista renderer y no tiene tests /// dedicados afuera. Si crece su uso se puede mover a meta-runtime. fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> { let mut cur = v; for seg in path.split('.') { cur = cur.get(seg)?; } 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 // 7 slots que las funciones render_* usan; las firmas // siguen tomando los colors individuales (Hsla / Background) // para preservar el shape del API interno. // // Si el caller no instaló un Theme, `Theme::global` panicea. // Convención: el binario shell instala el theme en main. let theme = Theme::global(cx).clone(); let bg = theme.bg_app; let panel = theme.bg_panel; let border = theme.border; let text = theme.fg_text; let text_dim = theme.fg_muted; let accent = theme.accent; let accent_active = theme.accent_strong; let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active); let main_panel = self.render_main(cx, panel, border, text, text_dim, accent); let confirm_banner = self.render_confirm_delete_banner(cx); let toast_div = self .toast .as_ref() .map(|t| banner_themed(cx, Banner::Success, t.clone())); let error_banner = self .load_error .as_ref() .map(|e| banner_themed(cx, Banner::Error, e.clone())); div() .flex() .flex_col() .size_full() .bg(bg) // Capture phase: el Esc llega al root ANTES que cualquier // TextInput descendiente. Si hay un delete pendiente, lo // cancelamos. Sin pending no hacemos nada (el evento sigue // su flujo normal y el TextInput recibe el Esc bubble). .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _w, cx| { if event.keystroke.key != "escape" { return; } if let Some((entity, _id)) = this.pending_delete.take() { this.toast = Some(SharedString::from(format!( "delete cancelado ({entity}) [esc]" ))); cx.notify(); } })) .when_some(error_banner, |d, b| d.child(b)) .when_some(confirm_banner, |d, b| d.child(b)) .child( div() .flex() .flex_row() .flex_grow() .child(sidebar) .child(main_panel), ) .when_some(toast_div, |d, t| d.child(t)) } } impl MetaApp { /// Renderea el banner modal de confirmación cuando hay un delete /// pendiente. Devuelve `None` si no hay nada que confirmar. /// /// UX: banner amber prominente arriba de todo el contenido. No es /// un overlay flotante (GPUI no expone z-index fácilmente sin /// setup) — es un row del flex_col raíz, así que el contenido /// debajo se desplaza unos pixels mientras está activo. Suficiente /// para forzar al usuario a leer + click. fn render_confirm_delete_banner(&self, cx: &mut Context) -> Option { let (entity, id) = self.pending_delete.as_ref()?; let entity_owned = entity.clone(); let id_owned = *id; let entity_for_cancel = entity_owned.clone(); let id_short = short_uuid(&id_owned); let entity_for_confirm = entity_owned.clone(); // Banner base usa los colors themed del kind Warning (amber // que sigue is_dark del theme actual). Los buttons confirm // (rojo) y cancel (gris) usan los colors themed del Error // y un derivative del border respectivamente. let theme = Theme::global(cx); let (banner_bg, banner_text) = themed_colors(Banner::Warning, theme); let (confirm_bg, confirm_text) = themed_colors(Banner::Error, theme); let cancel_bg: gpui::Background = theme.bg_panel_alt; let cancel_text = theme.fg_text; // Hover colors capturados antes de las closures para que el // move |d| d.bg(...) los cierre. let cancel_hover = theme.bg_button_hover(); let confirm_hover = theme.bg_destructive_hover(); let hint_color = theme.fg_muted; Some( div() .flex() .flex_row() .items_center() .gap(px(12.)) .px(px(12.)) .py(px(8.)) .bg(banner_bg) .text_color(banner_text) .text_size(px(12.)) .child( div() .flex_grow() .flex() .flex_col() .child(format!("¿Borrar {entity_owned} {id_short}?")) .child( div() .text_size(px(10.)) .text_color(hint_color) .child("Esc para cancelar · click [Confirmar] para borrar"), ), ) .child( div() .id(SharedString::from(format!( "confirm-del-cancel-{}", id_owned ))) .px(px(10.)) .py(px(4.)) .bg(cancel_bg) .text_color(cancel_text) .hover(move |d| d.bg(cancel_hover)) .child("Cancelar") .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { this.pending_delete = None; this.toast = Some(SharedString::from(format!( "delete cancelado ({entity_for_cancel})" ))); cx.notify(); })), ) .child( div() .id(SharedString::from(format!("confirm-del-ok-{}", id_owned))) .px(px(10.)) .py(px(4.)) .bg(confirm_bg) .text_color(confirm_text) .hover(move |d| d.bg(confirm_hover)) .child("Confirmar") .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { // Limpiar primero para que un fallo del // commit no deje el banner colgado y el // toast tape el banner. this.pending_delete = None; match this.commit_delete(&entity_for_confirm, id_owned) { Ok(outcome) => { let base = format!( "borrado {entity_for_confirm} {}", short_uuid(&id_owned) ); this.toast = Some(append_compact_msg(base, outcome.post_status)); } Err(e) => { this.toast = Some(SharedString::from(format!("error borrando: {e}"))); } } cx.notify(); })), ), ) } fn render_sidebar( &self, cx: &mut Context, panel: gpui::Background, border: gpui::Hsla, text: gpui::Hsla, text_dim: gpui::Hsla, accent_active: gpui::Hsla, ) -> gpui::Div { // Slots ornament del theme para los menu items de abajo. let theme = Theme::global(cx); let menu_active_bg = theme.bg_row_active; let menu_hover_bg = theme.bg_row_hover; let mut sidebar = div() .w(px(240.)) .h_full() .bg(panel) .border_r_1() .border_color(border) .flex() .flex_col(); // Sidebar header: título + theme switcher en una row. sidebar = sidebar.child( div() .flex() .flex_row() .items_center() .px(px(12.)) .py(px(10.)) .text_color(text) .text_size(px(13.)) .child(div().flex_grow().child("Nakui")) .child(theme_switcher(cx)), ); if self.modules.is_empty() { return sidebar.child( div() .px(px(12.)) .py(px(8.)) .text_color(text_dim) .text_size(px(11.)) .child("(no hay módulos cargados)"), ); } // Snapshot del active para evitar borrow del self adentro de la closure. let active_snapshot = self.active.clone(); for (mod_idx, m) in self.modules.iter().enumerate() { sidebar = sidebar.child( div() .px(px(12.)) .py(px(8.)) .border_t_1() .border_color(border) .text_color(text) .text_size(px(12.)) .child(m.label.clone()), ); for item in &m.menu { let is_active = active_snapshot .as_ref() .map(|(i, v)| *i == mod_idx && v == &item.view) .unwrap_or(false); let label = item .icon .as_deref() .map(|ic| format!("{ic} {}", item.label)) .unwrap_or_else(|| item.label.clone()); let view_key = item.view.clone(); sidebar = sidebar.child( div() .id(SharedString::from(format!( "menu-{}-{}", mod_idx, item.view ))) .px(px(20.)) .py(px(6.)) .text_size(px(12.)) .text_color(if is_active { accent_active } else { text_dim }) .when(is_active, move |d| d.bg(menu_active_bg).text_color(text)) .hover(move |d| d.bg(menu_hover_bg)) .child(label) .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { this.select_view(mod_idx, view_key.clone(), cx); })), ); } } sidebar } fn render_main( &mut self, cx: &mut Context, panel: gpui::Background, border: gpui::Hsla, text: gpui::Hsla, text_dim: gpui::Hsla, accent: gpui::Hsla, ) -> gpui::Div { let main = div() .flex_grow() .h_full() .bg(panel) .flex() .flex_col() .p(px(16.)); let (mod_idx, view_key) = match &self.active { Some(a) => (a.0, a.1.clone()), None => { return main.child( div() .text_color(text_dim) .child("Seleccioná un menú a la izquierda."), ); } }; let view = match self .modules .get(mod_idx) .and_then(|m| m.views.get(&view_key)) { Some(v) => v.clone(), None => { return main.child( div() .text_color(text_dim) .child(format!("Vista no encontrada: {view_key}")), ); } }; match view { View::List(lv) => { self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent) } View::Form(fv) => { self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent) } View::Detail(dv) => { self.render_detail(cx, main, &dv, mod_idx, border, text, text_dim, accent) } View::Dashboard(dv) => { self.render_dashboard(cx, main, &dv, border, text, text_dim, accent) } } } #[allow(clippy::too_many_arguments)] fn render_list( &self, cx: &mut Context, mut main: gpui::Div, lv: &ListView, mod_idx: usize, border: gpui::Hsla, text: gpui::Hsla, text_dim: gpui::Hsla, accent: gpui::Hsla, ) -> gpui::Div { // Ornament secundarios del theme para hovers, row separators, // botones inline (edit ✎, delete ✕). let theme = Theme::global(cx); let row_separator = theme.bg_row_active; let action_bg = theme.bg_button(); let action_hover = theme.bg_button_hover(); let destructive_fg = theme.accent_destructive(); let destructive_hover = theme.bg_destructive_hover(); let mut header = div() .flex() .flex_row() .items_center() .gap(px(12.)) .mb(px(12.)) .child( div() .text_color(text) .text_size(px(18.)) .child(lv.title.clone()), ); for (idx, action) in lv.actions.iter().enumerate() { let label = match action { Action::OpenView { label, view } => { label.clone().unwrap_or_else(|| format!("→ {view}")) } Action::SeedEntity { entity, .. } => format!("Seed {entity}"), Action::Morphism { name, .. } => format!("⚡ {name}"), }; let action_clone = action.clone(); header = header.child( div() .id(SharedString::from(format!("list-action-{mod_idx}-{idx}"))) .px(px(10.)) .py(px(4.)) .bg(action_bg) .text_color(accent) .text_size(px(11.)) .rounded(px(3.)) .hover(move |d| d.bg(action_hover)) .child(label) .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { this.apply_action(action_clone.clone(), cx); })), ); } main = main.child(header); // 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() .flex_row() .py(px(6.)) .border_b_1() .border_color(border) .text_color(text_dim) .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)) .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 .child(div().w(px(80.)).text_color(text_dim).child("id")) .child(div().w(px(100.)).text_color(text_dim).child("acciones")); main = main.child(col_header); let entity_name = lv.entity.clone(); for (id, value) in &rows { let id_copy = *id; let entity_for_edit = entity_name.clone(); let entity_for_delete = entity_name.clone(); let mut row = div() .flex() .flex_row() .py(px(6.)) .border_b_1() .border_color(row_separator) .text_color(text) .text_size(px(12.)); for c in &lv.columns { let frac = c.weight / total_weight; let v = lookup_field(value, &c.field); row = row.child( div() .flex_grow() .flex_basis(px(100. * frac)) .child(self.render_cell(c, v)), ); } row = row.child( div() .w(px(80.)) .text_color(text_dim) .text_size(px(11.)) .child(short_uuid(id)), ); // Acciones por fila: 👁 ver (si hay row_detail) + ✎ + ✕. let mut actions = div().w(px(100.)).flex().flex_row().gap(px(4.)); if let Some(detail_view) = &row_detail { let dv_key = detail_view.clone(); actions = actions.child( div() .id(SharedString::from(format!("row-view-{mod_idx}-{id_copy}"))) .px(px(6.)) .text_color(text_dim) .text_size(px(13.)) .hover(move |d| d.bg(action_hover)) .child("👁") .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { this.select_detail(mod_idx, dv_key.clone(), id_copy, cx); })), ); } row = row.child( actions .child( div() .id(SharedString::from(format!("row-edit-{mod_idx}-{id_copy}"))) .px(px(6.)) .text_color(accent) .text_size(px(13.)) .hover(move |d| d.bg(action_hover)) .child("✎") .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx); })), ) .child( div() .id(SharedString::from(format!("row-del-{mod_idx}-{id_copy}"))) .px(px(6.)) .text_color(destructive_fg) .text_size(px(13.)) .hover(move |d| d.bg(destructive_hover)) .child("✕") .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { // Marca para borrar en lugar de borrar // directo: el modal de confirmación se // renderea arriba en `render` y maneja // confirm/cancel. this.pending_delete = Some((entity_for_delete.clone(), id_copy)); this.toast = None; cx.notify(); })), ), ); main = main.child(row); } 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(msg), ); } else { 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 } /// Renderea el selector clickable de records existentes para un /// FieldSpec con kind=EntityRef. Lista compacta debajo del input; /// click en una opción setea el TextInput del field con el UUID /// seleccionado. El item del UUID actualmente seleccionado (si /// hay) se resalta con accent color. /// Renderea una ficha de detalle: campos del record + listas de /// records relacionados (back-references) + botones Volver/Editar. #[allow(clippy::too_many_arguments)] fn render_detail( &self, cx: &mut Context, mut main: gpui::Div, dv: &DetailView, mod_idx: usize, border: gpui::Hsla, text: gpui::Hsla, text_dim: gpui::Hsla, accent: gpui::Hsla, ) -> gpui::Div { let theme = Theme::global(cx); let action_bg = theme.bg_button(); let action_hover = theme.bg_button_hover(); let row_separator = theme.bg_row_active; let back_view = self.detail_return.clone(); let mut header = div() .flex() .flex_row() .items_center() .gap(px(10.)) .mb(px(12.)) .child( div() .text_color(text) .text_size(px(18.)) .flex_grow() .child(dv.title.clone()), ) .child( div() .id("detail-back") .px(px(10.)) .py(px(4.)) .bg(action_bg) .text_color(accent) .text_size(px(11.)) .rounded(px(3.)) .hover(move |d| d.bg(action_hover)) .child("← Volver") .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { match back_view.clone() { Some(v) => this.select_view(mod_idx, v, cx), None => { if let Some(first) = this .modules .get(mod_idx) .and_then(|m| m.menu.first().map(|i| i.view.clone())) { this.select_view(mod_idx, first, cx); } } } })), ); let Some(target_id) = self.detail_target else { return main.child(header).child( div() .text_color(text_dim) .child("Ningún record seleccionado."), ); }; let entity_for_edit = dv.entity.clone(); header = header.child( div() .id("detail-edit") .px(px(10.)) .py(px(4.)) .bg(action_bg) .text_color(accent) .text_size(px(11.)) .rounded(px(3.)) .hover(move |d| d.bg(action_hover)) .child("✎ Editar") .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { this.open_edit(mod_idx, entity_for_edit.clone(), target_id, cx); })), ); main = main.child(header); let Some(record) = self.backend.load_record(&dv.entity, target_id) else { return main.child(div().text_color(text_dim).child(format!( "El record {} ya no existe.", short_uuid(&target_id) ))); }; // Campos del record. let mut fields_box = div().flex().flex_col(); for c in &dv.fields { let v = lookup_field(&record, &c.field); fields_box = fields_box.child( div() .flex() .flex_row() .gap(px(12.)) .py(px(4.)) .border_b_1() .border_color(row_separator) .child( div() .w(px(160.)) .flex_none() .text_color(text_dim) .text_size(px(12.)) .child(c.label.clone()), ) .child( div() .flex_grow() .text_color(text) .text_size(px(12.)) .child(self.render_cell(c, v)), ), ); } main = main.child(fields_box); // Listas de records relacionados. for rl in &dv.related { main = main.child(self.render_related(rl, target_id, border, text, text_dim)); } main } /// Renderea una lista de records relacionados (back-reference): /// los records de `rl.entity` cuyo `rl.via_field` apunta al record /// `target_id`. fn render_related( &self, rl: &RelatedList, target_id: Uuid, border: gpui::Hsla, text: gpui::Hsla, text_dim: gpui::Hsla, ) -> gpui::Div { let id_str = target_id.to_string(); let rows: Vec<(Uuid, Value)> = self .backend .list_records(&rl.entity) .into_iter() .filter(|(_, v)| v.get(&rl.via_field).and_then(Value::as_str) == Some(id_str.as_str())) .collect(); let mut section = div().flex().flex_col().mt(px(18.)).child( div() .text_color(text) .text_size(px(13.)) .mb(px(4.)) .child(format!("{} ({})", rl.title, rows.len())), ); if rows.is_empty() { return section.child( div() .py(px(4.)) .text_color(text_dim) .text_size(px(11.)) .child("(ninguno)"), ); } let total_weight: f32 = rl.columns.iter().map(|c| c.weight).sum::().max(0.01); let mut head = div() .flex() .flex_row() .py(px(4.)) .border_b_1() .border_color(border) .text_color(text_dim) .text_size(px(10.)); for c in &rl.columns { head = head.child( div() .flex_grow() .flex_basis(px(100. * c.weight / total_weight)) .child(c.label.clone()), ); } section = section.child(head); for (_, v) in &rows { let mut row = div() .flex() .flex_row() .py(px(3.)) .text_color(text) .text_size(px(11.)); for c in &rl.columns { row = row.child( div() .flex_grow() .flex_basis(px(100. * c.weight / total_weight)) .child(self.render_cell(c, lookup_field(v, &c.field))), ); } section = section.child(row); } section } /// Renderea un tablero: una grilla de tarjetas de KPI, cada una con /// su agregado computado sobre los records de su entity. #[allow(clippy::too_many_arguments)] fn render_dashboard( &self, cx: &mut Context, mut main: gpui::Div, dv: &DashboardView, border: gpui::Hsla, text: gpui::Hsla, text_dim: gpui::Hsla, accent: gpui::Hsla, ) -> gpui::Div { let card_bg = Theme::global(cx).bg_panel_alt; main = main.child( div() .text_color(text) .text_size(px(18.)) .mb(px(12.)) .child(dv.title.clone()), ); let mut grid = div().flex().flex_row().flex_wrap().gap(px(12.)); for card in &dv.cards { let records = self.backend.list_records(&card.entity); let result = compute_metric(&card.metric, card.filter.as_ref(), &records); let mut card_box = div() .flex() .flex_col() .gap(px(6.)) .p(px(14.)) .min_w(px(190.)) .bg(card_bg) .border_1() .border_color(border) .rounded(px(8.)) .child( div() .text_color(text_dim) .text_size(px(11.)) .child(card.label.clone()), ); match result { MetricResult::Scalar(s) => { // Entero si no tiene parte decimal — `Count` y sumas // de enteros se ven sin `.00`. let value = if s.fract() == 0.0 { Value::from(s as i64) } else { Value::from(s) }; card_box = card_box.child( div() .text_color(accent) .text_size(px(26.)) .child(format_value(Some(&value), &card.format)), ); } MetricResult::Breakdown(rows) => { if rows.is_empty() { card_box = card_box.child( div() .text_color(text_dim) .text_size(px(11.)) .child("(sin datos)"), ); } let max = rows.iter().map(|(_, n)| *n).max().unwrap_or(1).max(1); for (key, n) in rows { let bar = "█".repeat((n * 12 / max).max(1)); card_box = card_box.child( div() .flex() .flex_row() .items_center() .gap(px(6.)) .text_size(px(11.)) .child(div().w(px(96.)).flex_none().text_color(text).child(key)) .child(div().flex_grow().text_color(accent).child(bar)) .child(div().text_color(text_dim).child(n.to_string())), ); } } } grid = grid.child(card_box); } main.child(grid) } /// Render del valor de una celda de lista. Una columna con /// `ref_entity` resuelve su UUID al label del record referido; el /// resto aplica el `ValueFormat` declarado en la columna. fn render_cell(&self, c: &Column, v: Option<&Value>) -> String { if let Some(ref_entity) = &c.ref_entity { return match v { Some(Value::String(s)) => match Uuid::parse_str(s) { Ok(uuid) => self .backend .load_record(ref_entity, uuid) .map(|rec| human_label_for_record(&rec, &uuid)) .unwrap_or_else(|| format!("(borrado · {})", short_uuid(&uuid))), Err(_) => render_value(v), }, _ => render_value(v), }; } format_value(v, &c.format) } /// Label legible del record referenciado por un campo EntityRef. /// `(sin seleccionar)` si el campo está vacío. fn ref_label(&self, target: &str, current: &str) -> String { let current = current.trim(); if current.is_empty() { return "(sin seleccionar)".to_string(); } match Uuid::parse_str(current) { Ok(uuid) => self .backend .load_record(target, uuid) .map(|rec| human_label_for_record(&rec, &uuid)) .unwrap_or_else(|| format!("(borrado · {})", short_uuid(&uuid))), Err(_) => current.to_string(), } } /// Chips clickables para un campo [`FieldKind::Select`]. El chip de /// la opción elegida se resalta con accent. Click setea el /// `TextInput` del field (de donde lee el submit), igual que el /// selector de EntityRef. fn render_select_chips( &self, cx: &mut Context, field_name: String, options: &[SelectOption], text_dim: gpui::Hsla, accent: gpui::Hsla, ) -> gpui::Div { let theme = Theme::global(cx); let row_active = theme.bg_row_active; let row_hover = theme.bg_row_hover; let border = theme.border; let current = self .form_inputs .get(&field_name) .map(|inp| inp.read(&*cx).text().to_string()) .unwrap_or_default(); let mut row = div().mt(px(2.)).flex().flex_row().flex_wrap().gap(px(4.)); for opt in options { let value = opt.value.clone(); let is_selected = current == value; let field_for_click = field_name.clone(); let value_for_click = value.clone(); row = row.child( div() .id(SharedString::from(format!("select-{field_name}-{value}"))) .px(px(8.)) .py(px(3.)) .rounded(px(10.)) .border_1() .border_color(if is_selected { accent } else { border }) .text_size(px(11.)) .text_color(if is_selected { accent } else { text_dim }) .when(is_selected, move |d| d.bg(row_active)) .hover(move |d| d.bg(row_hover)) .child(opt.display().to_string()) .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { if let Some(input) = this.form_inputs.get(&field_for_click) { input.update(cx, |inp, cx| inp.set_text(value_for_click.clone(), cx)); } cx.notify(); })), ); } row } #[allow(clippy::too_many_arguments)] fn render_entity_ref_selector( &self, cx: &mut Context, field_name: String, target_entity: String, text: gpui::Hsla, text_dim: gpui::Hsla, accent: gpui::Hsla, ) -> gpui::Div { let _ = text; // Slots ornament para hover/selected del selector + border. let theme = Theme::global(cx); let row_active = theme.bg_row_active; let row_hover = theme.bg_row_hover; let rows = self.list_rows(&target_entity); let current = self .form_inputs .get(&field_name) .map(|inp| inp.read(&*cx).text().to_string()) .unwrap_or_default(); let mut container = div() .mt(px(4.)) .pl(px(8.)) .border_l_2() .border_color(theme.border) .flex() .flex_col() .gap(px(2.)); if rows.is_empty() { return container.child( div() .px(px(6.)) .py(px(4.)) .text_color(text_dim) .text_size(px(10.)) .child(format!( "(sin {target_entity}: creá uno antes para referenciar)" )), ); } container = container.child( div() .text_color(text_dim) .text_size(px(10.)) .child(format!("Seleccioná un {target_entity}:")), ); for (id, value) in &rows { let label = human_label_for_record(value, id); let id_str = id.to_string(); let is_selected = current == id_str; let field_for_click = field_name.clone(); let id_for_click = id_str.clone(); container = container.child( div() .id(SharedString::from(format!( "entity-ref-{field_name}-{id_str}" ))) .px(px(6.)) .py(px(2.)) .text_size(px(11.)) .text_color(if is_selected { accent } else { text_dim }) .when(is_selected, move |d| d.bg(row_active)) .hover(move |d| d.bg(row_hover)) .child(label) .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { if let Some(input) = this.form_inputs.get(&field_for_click) { input.update(cx, |inp, cx| inp.set_text(id_for_click.clone(), cx)); } cx.notify(); })), ); } container } #[allow(clippy::too_many_arguments)] fn render_form( &self, cx: &mut Context, mut main: gpui::Div, fv: &FormView, mod_idx: usize, _border: gpui::Hsla, text: gpui::Hsla, text_dim: gpui::Hsla, accent: gpui::Hsla, ) -> gpui::Div { // Slots ornament para el botón submit + bg de fallback inputs. let theme = Theme::global(cx); let submit_bg = theme.bg_button(); let submit_hover = theme.bg_button_hover(); let input_bg = theme.bg_input(); // En modo edit, el título refleja eso para que el user no // se confunda creyendo que hace alta nueva. let title = match self.editing.as_ref() { Some((e, id)) if e == &fv.entity => { format!("Editar {} {}", fv.entity, short_uuid(id)) } _ => fv.title.clone(), }; main = main.child( div() .text_color(text) .text_size(px(18.)) .mb(px(12.)) .child(title), ); for f in &fv.fields { let label = if f.required { format!("{} *", f.label) } else { f.label.clone() }; let mut field_box = div().flex().flex_col().mb(px(10.)).child( div() .text_color(text_dim) .text_size(px(11.)) .mb(px(2.)) .child(label), ); // Render según el kind. AutoId y Select NO montan el // TextInput editable —aunque sí existe en `form_inputs`, de // donde lee el submit—; presentan su propio control. match f.kind { FieldKind::AutoId => { // Read-only: el UUID autogenerado, sólo informativo. let val = self .form_inputs .get(&f.name) .map(|i| i.read(&*cx).text().to_string()) .unwrap_or_default(); field_box = field_box.child( div() .px(px(8.)) .py(px(6.)) .bg(input_bg) .text_color(text_dim) .text_size(px(11.)) .child(format!("{val} · automático")), ); } FieldKind::Select => { field_box = field_box.child(self.render_select_chips( cx, f.name.clone(), &f.options, text_dim, accent, )); } FieldKind::EntityRef => { // Display read-only del record elegido (label, no // el UUID crudo) + selector clickable debajo. El // TextInput vive en `form_inputs` pero no se monta. if let Some(target) = &f.ref_entity { let current = self .form_inputs .get(&f.name) .map(|i| i.read(&*cx).text().to_string()) .unwrap_or_default(); let is_empty = current.trim().is_empty(); field_box = field_box.child( div() .px(px(8.)) .py(px(6.)) .bg(input_bg) .text_color(if is_empty { text_dim } else { text }) .text_size(px(11.)) .child(self.ref_label(target, ¤t)), ); field_box = field_box.child(self.render_entity_ref_selector( cx, f.name.clone(), target.clone(), text, text_dim, accent, )); } // Sin ref_entity es imposible: Module::validate lo // rechaza al cargar el módulo. } _ => { // Mount del TextInput vivo (creado en select_view). if let Some(input) = self.form_inputs.get(&f.name) { field_box = field_box.child(input.clone()); } else { // No debería pasar — select_view crea inputs por // cada field. Fallback estático por seguridad. field_box = field_box.child( div() .px(px(8.)) .py(px(6.)) .bg(input_bg) .text_color(text_dim) .child("(input no inicializado)"), ); } } } if let Some(help) = &f.help { field_box = field_box.child( div() .mt(px(2.)) .text_color(text_dim) .text_size(px(10.)) .child(help.clone()), ); } main = main.child(field_box); } let editing_this = matches!( self.editing.as_ref(), Some((e, _)) if e == &fv.entity ); let submit_label = match &fv.on_submit { Action::SeedEntity { entity, .. } => { if editing_this { format!("Guardar cambios en {entity}") } else { format!("Crear {entity}") } } Action::Morphism { name, .. } => format!("Ejecutar {name}"), Action::OpenView { label, view } => { label.clone().unwrap_or_else(|| format!("Ir a {view}")) } }; let submit_action = fv.on_submit.clone(); main = main.child( div().mt(px(12.)).child( div() .id(SharedString::from(format!("form-submit-{mod_idx}"))) .px(px(12.)) .py(px(6.)) .bg(submit_bg) .text_color(accent) .text_size(px(12.)) .rounded(px(3.)) .hover(move |d| d.bg(submit_hover)) .child(submit_label) .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| { this.apply_action(submit_action.clone(), cx); })), ), ); main = main.child( div() .mt(px(20.)) .text_color(text_dim) .text_size(px(10.)) .child( "Tip: click en el campo para enfocar; Enter no envía (todavía), \ usá el botón. Backspace borra el último carácter.", ), ); main } } #[cfg(test)] mod tests { //! Tests del widget. Funciones puras (sin GPUI cx) sólo. Los //! tests de backend impl viven en el binario que provee el //! backend (ej: nakui-ui). Los helpers movidos a //! nahual-meta-runtime se testean allí. use super::*; use serde_json::json; #[test] fn lookup_field_simple_and_nested() { let v = json!({ "name": "Acme", "address": { "city": "Bogotá", "country": "CO" } }); assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme")); assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá")); assert!(lookup_field(&v, "missing").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] fn append_compact_msg_handles_both_branches() { // Sin compact: base solo, sin separador. let s = append_compact_msg("creado X".into(), None); assert_eq!(s.as_ref(), "creado X"); // Con compact: concatena con "; ". let s = append_compact_msg( "creado X".into(), Some("auto-compact: snapshot @ seq 49".into()), ); assert_eq!(s.as_ref(), "creado X; auto-compact: snapshot @ seq 49"); } #[test] fn format_seed_toast_distinguishes_create_update_no_change() { let id = Uuid::new_v4(); let outcome_create = WriteOutcome { id: Some(id), changed: 1, post_status: None, }; let toast = format_seed_toast("Customer", false, &outcome_create); assert!(toast.starts_with("creado Customer")); let outcome_update = WriteOutcome { id: Some(id), changed: 3, post_status: None, }; let toast = format_seed_toast("Customer", true, &outcome_update); assert!(toast.starts_with("actualizado Customer")); assert!(toast.contains("(3 campo(s))")); let outcome_no_change = WriteOutcome::no_change(id); let toast = format_seed_toast("Customer", true, &outcome_no_change); assert!(toast.contains("sin cambios")); } }