⎇ b13486e240239baedd305d909fe9ed5d4f5f687e
crates/modules/nahual/widgets/meta-form/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(nakui): Fase 6 del ERP — export CSV de listas
1//! `nahual-widget-meta-form` — widget GPUI de metainterfaz.
2//!
3//! Renderea cualquier conjunto de [`nahual_meta_schema::Module`] sobre
4//! cualquier impl de [`nahual_meta_runtime::MetaBackend`]: sidebar con
5//! menús, list/form views, modal de delete, EntityRef selector.
6//!
7//! El widget es **app-agnostic**: no asume Nakui, ni storage
8//! particular, ni lifecycle de bootstrapping. El binario que lo
9//! consume:
10//!
11//! 1. Carga sus módulos (típicamente via `brahman-cards`).
12//! 2. Construye su backend concreto (typicamente wireado a un
13//! store/log/executor de su stack).
14//! 3. Crea `MetaApp::new(modules, backend, initial_toast,
15//! initial_error, cx)` y lo monta como root view de GPUI.
16//!
17//! Para una metainterfaz Nakui-completa ver `nakui-ui` (binario
18//! shell que provee `NakuiBackend`).
19
20#![forbid(unsafe_code)]
21
22use std::collections::BTreeMap;
23
24use gpui::{
25 div, prelude::*, px, ClickEvent, Context, Entity, IntoElement, KeyDownEvent, Render,
26 SharedString, Window,
27};
28
29use nahual_meta_runtime::{
30 cmp_values, compute_clear_fields, compute_field_delta, compute_metric, format_value,
31 human_label_for_record, parse_field_value, render_value, resolve_param_value, short_uuid,
32 to_csv, validate_entity_refs, value_to_input_text, MetaBackend, MetricResult, WriteOutcome,
33};
34use nahual_meta_schema::{
35 Action, Column, DashboardView, DetailView, FieldKind, FieldSpec, FormView, ListView, Module,
36 RelatedList, SelectOption, View,
37};
38use nahual_theme::Theme;
39use nahual_widget_banner::{banner_themed, themed_colors, Banner};
40use nahual_widget_text_input::TextInput;
41use nahual_widget_theme_switcher::theme_switcher;
42use serde_json::Value;
43use uuid::Uuid;
44
45/// Filas por página en las vistas de lista.
46const PAGE_SIZE: usize = 25;
47
48/// Estado del runtime de UI. Toda la persistencia/ejecución está
49/// detrás del trait [`MetaBackend`]; este struct sólo conoce GPUI
50/// state y el schema de los módulos.
51///
52/// Genérico sobre el backend `B`. Un binario decide qué backend
53/// proveer (Nakui via `NakuiBackend`, mock para tests, etc.).
54pub struct MetaApp<B: MetaBackend> {
55 /// Módulos cargados, ordenados por id.
56 modules: Vec<Module>,
57 /// Backend que ejecuta seed/update/delete/morphism.
58 backend: B,
59 /// (módulo idx, vista key) actualmente activos.
60 active: Option<(usize, String)>,
61 /// Inputs vivos para el form actual: nombre del campo → TextInput.
62 /// Se reemplaza al cambiar de vista (drop de los anteriores).
63 form_inputs: BTreeMap<String, Entity<TextInput>>,
64 /// Si está set, el próximo render del Form pre-llena los inputs
65 /// con los valores del record indicado, y `commit_seed` emite
66 /// un `update` (no un seed nuevo). Limpia al cambiar de view o
67 /// tras submit exitoso.
68 editing: Option<(String, Uuid)>,
69 /// Si está set, el banner modal de confirmación de delete está
70 /// activo: `(entity, id)` del record que el usuario marcó para
71 /// borrar. Permanece hasta que el usuario click [Confirmar]
72 /// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia).
73 /// Navegación a otra view también cancela.
74 pending_delete: Option<(String, Uuid)>,
75 /// Si la vista activa es una `Detail`, el id del record que se
76 /// muestra. Lo setea [`Self::select_detail`].
77 detail_target: Option<Uuid>,
78 /// Key de la vista a la que vuelve el botón «← Volver» de una
79 /// ficha — la lista desde la que se abrió.
80 detail_return: Option<String>,
81 /// Estado de la vista de lista activa. Se reinician al navegar.
82 /// `list_search`: caja de búsqueda (sólo si la lista declara
83 /// `search_in`). `list_sort`: `(columna, ascendente)`.
84 /// `list_page`: página actual (0-based).
85 list_search: Option<Entity<TextInput>>,
86 list_sort: Option<(String, bool)>,
87 list_page: usize,
88 /// Mensaje toast al pie (success de submit, error de carga, etc.).
89 toast: Option<SharedString>,
90 /// Si la carga de módulos falló al inicio.
91 load_error: Option<SharedString>,
92}
93
94impl<B: MetaBackend> MetaApp<B> {
95 /// Constructor del widget. El caller pre-construye sus módulos +
96 /// backend + cualquier mensaje inicial que quiera mostrar al
97 /// usuario al abrir (típicamente el toast del replay y el error
98 /// banner de cosas que fallaron de bootstrap).
99 ///
100 /// La active view default es la primera entry del menú del
101 /// primer módulo (orden lexicográfico por id).
102 pub fn new(
103 modules: Vec<Module>,
104 backend: B,
105 initial_toast: Option<String>,
106 initial_error: Option<String>,
107 _cx: &mut Context<Self>,
108 ) -> Self {
109 let active = modules
110 .first()
111 .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
112 Self {
113 modules,
114 backend,
115 active,
116 form_inputs: BTreeMap::new(),
117 editing: None,
118 pending_delete: None,
119 detail_target: None,
120 detail_return: None,
121 list_search: None,
122 list_sort: None,
123 list_page: 0,
124 toast: initial_toast.map(SharedString::from),
125 load_error: initial_error.map(SharedString::from),
126 }
127 }
128
129 /// Cambia la vista activa. Si la nueva vista es un Form, crea
130 /// `TextInput` entities para cada field. Pre-llena con valores
131 /// del record si hay `editing` para esa entity; si no, usa el
132 /// `default` del schema.
133 /// Drop de los inputs anteriores ocurre al sobreescribir el map.
134 fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context<Self>) {
135 self.active = Some((mod_idx, view_key.clone()));
136 self.toast = None;
137 // Navegar a otra view cancela cualquier delete pendiente:
138 // el record marcado puede no estar visible en la nueva view.
139 self.pending_delete = None;
140 self.detail_target = None;
141 self.form_inputs = BTreeMap::new();
142 self.list_search = None;
143 self.list_sort = None;
144 self.list_page = 0;
145 if let Some(module) = self.modules.get(mod_idx) {
146 match module.views.get(&view_key) {
147 Some(View::Form(form)) => {
148 // Snapshot del record si estamos editando esta entity.
149 let editing_record: Option<Value> =
150 self.editing.as_ref().and_then(|(e, id)| {
151 if e == &form.entity {
152 self.backend.load_record(e, *id)
153 } else {
154 None
155 }
156 });
157 for f in &form.fields {
158 let initial = if f.kind == FieldKind::AutoId {
159 // Editando: conservar el id del record.
160 // Alta: UUID nuevo, que el usuario no teclea.
161 editing_record
162 .as_ref()
163 .and_then(|rec| rec.get(&f.name).map(value_to_input_text))
164 .unwrap_or_else(|| Uuid::new_v4().to_string())
165 } else if let Some(rec) = &editing_record {
166 rec.get(&f.name)
167 .map(value_to_input_text)
168 .unwrap_or_else(|| f.default.clone().unwrap_or_default())
169 } else {
170 f.default.clone().unwrap_or_default()
171 };
172 let input = cx.new(|cx| TextInput::new(initial, cx));
173 self.form_inputs.insert(f.name.clone(), input);
174 }
175 }
176 Some(View::List(lv)) => {
177 // Caja de búsqueda viva si la lista declara search_in.
178 if !lv.search_in.is_empty() {
179 let input = cx.new(|cx| TextInput::new("", cx).with_placeholder("buscar…"));
180 // Re-render del widget cuando el input cambia →
181 // filtrado en vivo mientras se teclea.
182 cx.observe(&input, |_this, _, cx| cx.notify()).detach();
183 self.list_search = Some(input);
184 }
185 self.editing = None;
186 }
187 _ => {
188 // Cambiar a una view que no es Form invalida el
189 // editing pendiente.
190 self.editing = None;
191 }
192 }
193 }
194 cx.notify();
195 }
196
197 /// Abre la ficha de detalle `detail_view` para el record `id`.
198 /// Recuerda la vista actual para el botón «← Volver».
199 fn select_detail(
200 &mut self,
201 mod_idx: usize,
202 detail_view: String,
203 id: Uuid,
204 cx: &mut Context<Self>,
205 ) {
206 self.detail_return = self.active.as_ref().map(|(_, v)| v.clone());
207 self.active = Some((mod_idx, detail_view));
208 self.detail_target = Some(id);
209 self.editing = None;
210 self.pending_delete = None;
211 self.form_inputs = BTreeMap::new();
212 self.list_search = None;
213 self.list_sort = None;
214 self.list_page = 0;
215 self.toast = None;
216 cx.notify();
217 }
218
219 /// Cambia el orden de la lista al hacer clic en un header: misma
220 /// columna cicla ascendente → descendente → sin orden.
221 fn toggle_sort(&mut self, field: &str, cx: &mut Context<Self>) {
222 self.list_sort = next_sort(self.list_sort.take(), field);
223 self.list_page = 0;
224 cx.notify();
225 }
226
227 /// Inicia un edit del record: setea `editing` y abre la primera
228 /// view de tipo Form del módulo (convención: la del schema).
229 fn open_edit(&mut self, mod_idx: usize, entity: String, id: Uuid, cx: &mut Context<Self>) {
230 self.editing = Some((entity.clone(), id));
231 let form_view_key = self.modules.get(mod_idx).and_then(|m| {
232 m.views.iter().find_map(|(key, v)| match v {
233 View::Form(form) if form.entity == entity => Some(key.clone()),
234 _ => None,
235 })
236 });
237 match form_view_key {
238 Some(key) => self.select_view(mod_idx, key, cx),
239 None => {
240 self.toast = Some(SharedString::from(format!(
241 "no hay form view para entity '{entity}' en este módulo"
242 )));
243 self.editing = None;
244 cx.notify();
245 }
246 }
247 }
248
249 /// Borra un record. Emite Morphism con un FieldOp::Delete + lo
250 /// aplica al store via `apply` (no via remove directo, mantiene
251 /// el modelo de "todo cambio post-seed pasa por ops").
252 /// Borra un record vía `MetaBackend::delete`. Devuelve el outcome
253 /// del backend (incluye eventual `post_status` del compact tick).
254 fn commit_delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String> {
255 self.backend.delete(entity, id)
256 }
257
258 /// Aplica una acción (click en menú, botón de form, action de
259 /// list). Mutaciones contra el backend ocurren acá.
260 pub fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
261 let mod_idx = match self.active.as_ref() {
262 Some((i, _)) => *i,
263 None => return,
264 };
265 match action {
266 Action::OpenView { view, .. } => {
267 // Salir a otra view cancela el edit pendiente.
268 self.editing = None;
269 self.select_view(mod_idx, view, cx);
270 }
271 Action::SeedEntity { entity, next_view } => {
272 let was_editing = self.editing.is_some();
273 match self.commit_seed(mod_idx, &entity, cx) {
274 Ok(outcome) => {
275 let toast_msg = format_seed_toast(&entity, was_editing, &outcome);
276 self.toast = Some(append_compact_msg(toast_msg, outcome.post_status));
277 // Limpia editing tras un commit exitoso —
278 // el record ya está sincronizado (incluso
279 // un NoChange cierra el modo edit).
280 self.editing = None;
281 if let Some(v) = next_view {
282 self.select_view(mod_idx, v, cx);
283 } else {
284 for input in self.form_inputs.values() {
285 input.update(cx, |inp, cx| inp.set_text("", cx));
286 }
287 }
288 }
289 Err(e) => {
290 self.toast = Some(SharedString::from(format!("error: {e}")));
291 }
292 }
293 cx.notify();
294 }
295 Action::Morphism {
296 name,
297 inputs,
298 params,
299 next_view,
300 } => {
301 match self.commit_morphism(mod_idx, &name, &inputs, ¶ms, cx) {
302 Ok(outcome) => {
303 let base =
304 format!("morphism '{name}' OK ({} op(s) aplicadas)", outcome.changed);
305 self.toast = Some(append_compact_msg(base, outcome.post_status));
306 if let Some(v) = next_view {
307 self.select_view(mod_idx, v, cx);
308 }
309 }
310 Err(e) => {
311 self.toast =
312 Some(SharedString::from(format!("morphism '{name}' falló: {e}")));
313 }
314 }
315 cx.notify();
316 }
317 }
318 }
319
320 /// Despacha un morphism via el backend. Resuelve inputs (UUIDs)
321 /// + params (Value object) leyendo los TextInput del form.
322 fn commit_morphism(
323 &mut self,
324 mod_idx: usize,
325 morphism: &str,
326 inputs_map: &BTreeMap<String, String>,
327 params_fields: &[String],
328 cx: &mut Context<Self>,
329 ) -> Result<WriteOutcome, String> {
330 let module = self
331 .modules
332 .get(mod_idx)
333 .ok_or_else(|| "módulo inválido".to_string())?;
334 let module_id = module.id.clone();
335
336 // Resolver inputs: por cada (role, field_name), parsear el
337 // value del input como Uuid.
338 let mut inputs: BTreeMap<String, Uuid> = BTreeMap::new();
339 for (role, field_name) in inputs_map {
340 let raw = self
341 .form_inputs
342 .get(field_name)
343 .map(|inp| inp.read(&*cx).text().to_string())
344 .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
345 let id = Uuid::parse_str(raw.trim()).map_err(|_| {
346 format!("input '{role}' (field '{field_name}'): '{raw}' no es UUID válido")
347 })?;
348 inputs.insert(role.clone(), id);
349 }
350
351 // Resolver params: si la lista está vacía, todos los fields
352 // del form que no estén en `inputs_map` van a params.
353 let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect();
354 let mut params_obj = serde_json::Map::new();
355 let field_iter: Vec<String> = if params_fields.is_empty() {
356 self.form_inputs
357 .keys()
358 .filter(|k| !input_field_set.contains(*k))
359 .cloned()
360 .collect()
361 } else {
362 params_fields.to_vec()
363 };
364
365 // FieldSpec del Form view activo para parseo estricto por kind.
366 let active_form_fields: Option<Vec<FieldSpec>> =
367 self.active.as_ref().and_then(|(_, vk)| {
368 module.views.get(vk).and_then(|v| match v {
369 View::Form(f) => Some(f.fields.clone()),
370 _ => None,
371 })
372 });
373
374 for field_name in field_iter {
375 let raw = self
376 .form_inputs
377 .get(&field_name)
378 .map(|inp| inp.read(&*cx).text().to_string())
379 .unwrap_or_default();
380 let spec = active_form_fields
381 .as_ref()
382 .and_then(|fs| fs.iter().find(|f| f.name == field_name));
383 let value = resolve_param_value(&field_name, &raw, spec)?;
384 params_obj.insert(field_name, value);
385 }
386
387 self.backend
388 .morphism(&module_id, morphism, inputs, Value::Object(params_obj))
389 }
390
391 /// Construye un payload desde los TextInput vivos y delega al
392 /// backend (`seed` para alta nueva, `update` con set+clear para
393 /// edit). Devuelve el `WriteOutcome` del backend (incluye
394 /// `changed` y `post_status` para el toast).
395 fn commit_seed(
396 &mut self,
397 mod_idx: usize,
398 entity: &str,
399 cx: &mut Context<Self>,
400 ) -> Result<WriteOutcome, String> {
401 let module = &self.modules[mod_idx];
402 let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
403 Some((_, view_key)) => match module.views.get(view_key) {
404 Some(View::Form(f)) => f.fields.clone(),
405 _ => return Err("la vista activa no es un Form".into()),
406 },
407 None => return Err("ninguna vista activa".into()),
408 };
409 let mut obj = serde_json::Map::new();
410 // Fields que el form deja vacíos y son optional: candidatos
411 // a Clear en el path de EDIT.
412 let mut to_clear: Vec<String> = Vec::new();
413 // EntityRef refs a validar tras parse loop (en una toma del
414 // backend en lugar de N).
415 let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new();
416 for f in &spec_fields {
417 let raw = self
418 .form_inputs
419 .get(&f.name)
420 .map(|input| input.read(cx).text().to_string())
421 .unwrap_or_default();
422 if f.required && raw.trim().is_empty() {
423 return Err(format!("campo '{}' es obligatorio", f.label));
424 }
425 if raw.is_empty() && !f.required {
426 to_clear.push(f.name.clone());
427 continue;
428 }
429 let value =
430 parse_field_value(f.kind, &raw).map_err(|e| format!("campo '{}': {e}", f.label))?;
431 if f.kind == FieldKind::EntityRef {
432 if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
433 let id = Uuid::parse_str(uuid_str).expect("parse_field_value validated UUID");
434 entity_refs.push((f.label.clone(), target.clone(), id));
435 }
436 }
437 obj.insert(f.name.clone(), value);
438 }
439 // Validar EntityRefs contra el backend actual. Cierre wrappea
440 // backend.load_record para mantener la firma de
441 // validate_entity_refs (que es store-agnóstica).
442 if !entity_refs.is_empty() {
443 let backend = &self.backend;
444 validate_entity_refs(|e, id| backend.load_record(e, id), &entity_refs)?;
445 }
446
447 // Ramificación: edit (hay editing para esta entity) vs alta.
448 let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
449
450 if let Some((_, id)) = editing_match {
451 // EDIT: cargar current, computar delta, llamar
452 // backend.update con set+clear pre-computados.
453 let current = self.backend.load_record(entity, id).unwrap_or(Value::Null);
454 let set_delta = compute_field_delta(¤t, &obj);
455 let clear_fields = compute_clear_fields(¤t, &to_clear);
456 self.backend.update(entity, id, set_delta, clear_fields)
457 } else {
458 // SEED: alta nueva — el backend genera el Uuid.
459 self.backend.seed(entity, obj)
460 }
461 }
462
463 /// Snapshot ordenado de records de una entity (proxy al backend).
464 fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
465 self.backend.list_records(entity)
466 }
467
468 /// Filas de una lista tras aplicar la búsqueda y el orden activos
469 /// (sin paginar). Compartido por el render de la lista y el export
470 /// a CSV.
471 fn list_filtered_sorted(&self, lv: &ListView, cx: &mut Context<Self>) -> Vec<(Uuid, Value)> {
472 let mut rows = self.list_rows(&lv.entity);
473 let query = self
474 .list_search
475 .as_ref()
476 .map(|i| i.read(cx).text().trim().to_lowercase())
477 .unwrap_or_default();
478 if !query.is_empty() {
479 rows.retain(|(_, v)| {
480 lv.search_in.iter().any(|field| {
481 lookup_field(v, field)
482 .map(|cell| render_value(Some(cell)).to_lowercase().contains(&query))
483 .unwrap_or(false)
484 })
485 });
486 }
487 if let Some((field, asc)) = &self.list_sort {
488 rows.sort_by(|(_, a), (_, b)| {
489 let ord = cmp_values(lookup_field(a, field), lookup_field(b, field));
490 if *asc {
491 ord
492 } else {
493 ord.reverse()
494 }
495 });
496 }
497 rows
498 }
499
500 /// Exporta la lista (filas filtradas/ordenadas, todas las columnas)
501 /// a un archivo CSV en el directorio de trabajo; avisa por toast.
502 fn export_csv(&mut self, lv: &ListView, cx: &mut Context<Self>) {
503 let rows = self.list_filtered_sorted(lv, cx);
504 let headers: Vec<String> = lv.columns.iter().map(|c| c.label.clone()).collect();
505 let data: Vec<Vec<String>> = rows
506 .iter()
507 .map(|(_, v)| {
508 lv.columns
509 .iter()
510 .map(|c| self.render_cell(c, lookup_field(v, &c.field)))
511 .collect()
512 })
513 .collect();
514 let csv = to_csv(&headers, &data);
515 let path = export_path(&lv.entity);
516 self.toast = Some(SharedString::from(match std::fs::write(&path, csv) {
517 Ok(()) => format!("exporté {} fila(s) a {}", rows.len(), path.display()),
518 Err(e) => format!("no pude exportar CSV: {e}"),
519 }));
520 cx.notify();
521 }
522}
523
524/// Formatea el toast para la rama Action::SeedEntity según el
525/// `WriteOutcome` del backend. `was_editing` distingue "creado"
526/// vs "actualizado" — el WriteOutcome solo no alcanza porque
527/// `seed` y `update` ambos devuelven `id = Some(...)`.
528fn format_seed_toast(entity: &str, was_editing: bool, outcome: &WriteOutcome) -> String {
529 let id_short = outcome.id.map(|id| short_uuid(&id)).unwrap_or_default();
530 match (was_editing, outcome.changed) {
531 (false, _) => format!("creado {entity} {id_short}"),
532 (true, 0) => format!("{entity} {id_short} sin cambios — no log entry"),
533 (true, n) => format!("actualizado {entity} {id_short} ({n} campo(s))"),
534 }
535}
536
537/// Concatena el msg opcional del compact tick al toast del op
538/// original con `"; "` separator.
539fn append_compact_msg(base: String, compact_msg: Option<String>) -> SharedString {
540 match compact_msg {
541 Some(m) => SharedString::from(format!("{base}; {m}")),
542 None => SharedString::from(base),
543 }
544}
545
546/// Walker dentro de un `Value` por path con `.` como separador.
547/// Local porque sólo lo usa la lista renderer y no tiene tests
548/// dedicados afuera. Si crece su uso se puede mover a meta-runtime.
549fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
550 let mut cur = v;
551 for seg in path.split('.') {
552 cur = cur.get(seg)?;
553 }
554 Some(cur)
555}
556
557/// Ruta destino de un export CSV: `<entity>-<unix-secs>.csv` en el
558/// directorio de trabajo actual.
559fn export_path(entity: &str) -> std::path::PathBuf {
560 let secs = std::time::SystemTime::now()
561 .duration_since(std::time::UNIX_EPOCH)
562 .map(|d| d.as_secs())
563 .unwrap_or(0);
564 let name = format!("{entity}-{secs}.csv");
565 std::env::current_dir()
566 .map(|d| d.join(&name))
567 .unwrap_or_else(|_| std::path::PathBuf::from(name))
568}
569
570/// Próximo estado de orden al hacer clic en el header `field`: la misma
571/// columna cicla ascendente → descendente → sin orden; otra columna
572/// arranca ascendente.
573fn next_sort(current: Option<(String, bool)>, field: &str) -> Option<(String, bool)> {
574 match current {
575 Some((f, true)) if f == field => Some((f, false)),
576 Some((f, false)) if f == field => None,
577 _ => Some((field.to_string(), true)),
578 }
579}
580
581impl<B: MetaBackend> Render for MetaApp<B> {
582 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
583 // Paleta del chrome viene del Theme global. Derivamos los
584 // 7 slots que las funciones render_* usan; las firmas
585 // siguen tomando los colors individuales (Hsla / Background)
586 // para preservar el shape del API interno.
587 //
588 // Si el caller no instaló un Theme, `Theme::global` panicea.
589 // Convención: el binario shell instala el theme en main.
590 let theme = Theme::global(cx).clone();
591 let bg = theme.bg_app;
592 let panel = theme.bg_panel;
593 let border = theme.border;
594 let text = theme.fg_text;
595 let text_dim = theme.fg_muted;
596 let accent = theme.accent;
597 let accent_active = theme.accent_strong;
598
599 let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
600 let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
601 let confirm_banner = self.render_confirm_delete_banner(cx);
602 let toast_div = self
603 .toast
604 .as_ref()
605 .map(|t| banner_themed(cx, Banner::Success, t.clone()));
606 let error_banner = self
607 .load_error
608 .as_ref()
609 .map(|e| banner_themed(cx, Banner::Error, e.clone()));
610
611 div()
612 .flex()
613 .flex_col()
614 .size_full()
615 .bg(bg)
616 // Capture phase: el Esc llega al root ANTES que cualquier
617 // TextInput descendiente. Si hay un delete pendiente, lo
618 // cancelamos. Sin pending no hacemos nada (el evento sigue
619 // su flujo normal y el TextInput recibe el Esc bubble).
620 .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _w, cx| {
621 if event.keystroke.key != "escape" {
622 return;
623 }
624 if let Some((entity, _id)) = this.pending_delete.take() {
625 this.toast = Some(SharedString::from(format!(
626 "delete cancelado ({entity}) [esc]"
627 )));
628 cx.notify();
629 }
630 }))
631 .when_some(error_banner, |d, b| d.child(b))
632 .when_some(confirm_banner, |d, b| d.child(b))
633 .child(
634 div()
635 .flex()
636 .flex_row()
637 .flex_grow()
638 .child(sidebar)
639 .child(main_panel),
640 )
641 .when_some(toast_div, |d, t| d.child(t))
642 }
643}
644
645impl<B: MetaBackend> MetaApp<B> {
646 /// Renderea el banner modal de confirmación cuando hay un delete
647 /// pendiente. Devuelve `None` si no hay nada que confirmar.
648 ///
649 /// UX: banner amber prominente arriba de todo el contenido. No es
650 /// un overlay flotante (GPUI no expone z-index fácilmente sin
651 /// setup) — es un row del flex_col raíz, así que el contenido
652 /// debajo se desplaza unos pixels mientras está activo. Suficiente
653 /// para forzar al usuario a leer + click.
654 fn render_confirm_delete_banner(&self, cx: &mut Context<Self>) -> Option<gpui::Div> {
655 let (entity, id) = self.pending_delete.as_ref()?;
656 let entity_owned = entity.clone();
657 let id_owned = *id;
658 let entity_for_cancel = entity_owned.clone();
659 let id_short = short_uuid(&id_owned);
660 let entity_for_confirm = entity_owned.clone();
661
662 // Banner base usa los colors themed del kind Warning (amber
663 // que sigue is_dark del theme actual). Los buttons confirm
664 // (rojo) y cancel (gris) usan los colors themed del Error
665 // y un derivative del border respectivamente.
666 let theme = Theme::global(cx);
667 let (banner_bg, banner_text) = themed_colors(Banner::Warning, theme);
668 let (confirm_bg, confirm_text) = themed_colors(Banner::Error, theme);
669 let cancel_bg: gpui::Background = theme.bg_panel_alt;
670 let cancel_text = theme.fg_text;
671 // Hover colors capturados antes de las closures para que el
672 // move |d| d.bg(...) los cierre.
673 let cancel_hover = theme.bg_button_hover();
674 let confirm_hover = theme.bg_destructive_hover();
675 let hint_color = theme.fg_muted;
676
677 Some(
678 div()
679 .flex()
680 .flex_row()
681 .items_center()
682 .gap(px(12.))
683 .px(px(12.))
684 .py(px(8.))
685 .bg(banner_bg)
686 .text_color(banner_text)
687 .text_size(px(12.))
688 .child(
689 div()
690 .flex_grow()
691 .flex()
692 .flex_col()
693 .child(format!("¿Borrar {entity_owned} {id_short}?"))
694 .child(
695 div()
696 .text_size(px(10.))
697 .text_color(hint_color)
698 .child("Esc para cancelar · click [Confirmar] para borrar"),
699 ),
700 )
701 .child(
702 div()
703 .id(SharedString::from(format!(
704 "confirm-del-cancel-{}",
705 id_owned
706 )))
707 .px(px(10.))
708 .py(px(4.))
709 .bg(cancel_bg)
710 .text_color(cancel_text)
711 .hover(move |d| d.bg(cancel_hover))
712 .child("Cancelar")
713 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
714 this.pending_delete = None;
715 this.toast = Some(SharedString::from(format!(
716 "delete cancelado ({entity_for_cancel})"
717 )));
718 cx.notify();
719 })),
720 )
721 .child(
722 div()
723 .id(SharedString::from(format!("confirm-del-ok-{}", id_owned)))
724 .px(px(10.))
725 .py(px(4.))
726 .bg(confirm_bg)
727 .text_color(confirm_text)
728 .hover(move |d| d.bg(confirm_hover))
729 .child("Confirmar")
730 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
731 // Limpiar primero para que un fallo del
732 // commit no deje el banner colgado y el
733 // toast tape el banner.
734 this.pending_delete = None;
735 match this.commit_delete(&entity_for_confirm, id_owned) {
736 Ok(outcome) => {
737 let base = format!(
738 "borrado {entity_for_confirm} {}",
739 short_uuid(&id_owned)
740 );
741 this.toast =
742 Some(append_compact_msg(base, outcome.post_status));
743 }
744 Err(e) => {
745 this.toast =
746 Some(SharedString::from(format!("error borrando: {e}")));
747 }
748 }
749 cx.notify();
750 })),
751 ),
752 )
753 }
754
755 fn render_sidebar(
756 &self,
757 cx: &mut Context<Self>,
758 panel: gpui::Background,
759 border: gpui::Hsla,
760 text: gpui::Hsla,
761 text_dim: gpui::Hsla,
762 accent_active: gpui::Hsla,
763 ) -> gpui::Div {
764 // Slots ornament del theme para los menu items de abajo.
765 let theme = Theme::global(cx);
766 let menu_active_bg = theme.bg_row_active;
767 let menu_hover_bg = theme.bg_row_hover;
768 let mut sidebar = div()
769 .w(px(240.))
770 .h_full()
771 .bg(panel)
772 .border_r_1()
773 .border_color(border)
774 .flex()
775 .flex_col();
776
777 // Sidebar header: título + theme switcher en una row.
778 sidebar = sidebar.child(
779 div()
780 .flex()
781 .flex_row()
782 .items_center()
783 .px(px(12.))
784 .py(px(10.))
785 .text_color(text)
786 .text_size(px(13.))
787 .child(div().flex_grow().child("Nakui"))
788 .child(theme_switcher(cx)),
789 );
790
791 if self.modules.is_empty() {
792 return sidebar.child(
793 div()
794 .px(px(12.))
795 .py(px(8.))
796 .text_color(text_dim)
797 .text_size(px(11.))
798 .child("(no hay módulos cargados)"),
799 );
800 }
801
802 // Snapshot del active para evitar borrow del self adentro de la closure.
803 let active_snapshot = self.active.clone();
804
805 for (mod_idx, m) in self.modules.iter().enumerate() {
806 sidebar = sidebar.child(
807 div()
808 .px(px(12.))
809 .py(px(8.))
810 .border_t_1()
811 .border_color(border)
812 .text_color(text)
813 .text_size(px(12.))
814 .child(m.label.clone()),
815 );
816
817 for item in &m.menu {
818 let is_active = active_snapshot
819 .as_ref()
820 .map(|(i, v)| *i == mod_idx && v == &item.view)
821 .unwrap_or(false);
822 let label = item
823 .icon
824 .as_deref()
825 .map(|ic| format!("{ic} {}", item.label))
826 .unwrap_or_else(|| item.label.clone());
827
828 let view_key = item.view.clone();
829 sidebar = sidebar.child(
830 div()
831 .id(SharedString::from(format!(
832 "menu-{}-{}",
833 mod_idx, item.view
834 )))
835 .px(px(20.))
836 .py(px(6.))
837 .text_size(px(12.))
838 .text_color(if is_active { accent_active } else { text_dim })
839 .when(is_active, move |d| d.bg(menu_active_bg).text_color(text))
840 .hover(move |d| d.bg(menu_hover_bg))
841 .child(label)
842 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
843 this.select_view(mod_idx, view_key.clone(), cx);
844 })),
845 );
846 }
847 }
848 sidebar
849 }
850
851 fn render_main(
852 &mut self,
853 cx: &mut Context<Self>,
854 panel: gpui::Background,
855 border: gpui::Hsla,
856 text: gpui::Hsla,
857 text_dim: gpui::Hsla,
858 accent: gpui::Hsla,
859 ) -> gpui::Div {
860 let main = div()
861 .flex_grow()
862 .h_full()
863 .bg(panel)
864 .flex()
865 .flex_col()
866 .p(px(16.));
867
868 let (mod_idx, view_key) = match &self.active {
869 Some(a) => (a.0, a.1.clone()),
870 None => {
871 return main.child(
872 div()
873 .text_color(text_dim)
874 .child("Seleccioná un menú a la izquierda."),
875 );
876 }
877 };
878
879 let view = match self
880 .modules
881 .get(mod_idx)
882 .and_then(|m| m.views.get(&view_key))
883 {
884 Some(v) => v.clone(),
885 None => {
886 return main.child(
887 div()
888 .text_color(text_dim)
889 .child(format!("Vista no encontrada: {view_key}")),
890 );
891 }
892 };
893
894 match view {
895 View::List(lv) => {
896 self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent)
897 }
898 View::Form(fv) => {
899 self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent)
900 }
901 View::Detail(dv) => {
902 self.render_detail(cx, main, &dv, mod_idx, border, text, text_dim, accent)
903 }
904 View::Dashboard(dv) => {
905 self.render_dashboard(cx, main, &dv, border, text, text_dim, accent)
906 }
907 }
908 }
909
910 #[allow(clippy::too_many_arguments)]
911 fn render_list(
912 &self,
913 cx: &mut Context<Self>,
914 mut main: gpui::Div,
915 lv: &ListView,
916 mod_idx: usize,
917 border: gpui::Hsla,
918 text: gpui::Hsla,
919 text_dim: gpui::Hsla,
920 accent: gpui::Hsla,
921 ) -> gpui::Div {
922 // Ornament secundarios del theme para hovers, row separators,
923 // botones inline (edit ✎, delete ✕).
924 let theme = Theme::global(cx);
925 let row_separator = theme.bg_row_active;
926 let action_bg = theme.bg_button();
927 let action_hover = theme.bg_button_hover();
928 let destructive_fg = theme.accent_destructive();
929 let destructive_hover = theme.bg_destructive_hover();
930 let mut header = div()
931 .flex()
932 .flex_row()
933 .items_center()
934 .gap(px(12.))
935 .mb(px(12.))
936 .child(
937 div()
938 .text_color(text)
939 .text_size(px(18.))
940 .child(lv.title.clone()),
941 );
942 for (idx, action) in lv.actions.iter().enumerate() {
943 let label = match action {
944 Action::OpenView { label, view } => {
945 label.clone().unwrap_or_else(|| format!("→ {view}"))
946 }
947 Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
948 Action::Morphism { name, .. } => format!("⚡ {name}"),
949 };
950 let action_clone = action.clone();
951 header = header.child(
952 div()
953 .id(SharedString::from(format!("list-action-{mod_idx}-{idx}")))
954 .px(px(10.))
955 .py(px(4.))
956 .bg(action_bg)
957 .text_color(accent)
958 .text_size(px(11.))
959 .rounded(px(3.))
960 .hover(move |d| d.bg(action_hover))
961 .child(label)
962 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
963 this.apply_action(action_clone.clone(), cx);
964 })),
965 );
966 }
967 // Botón de export CSV — disponible en toda lista.
968 {
969 let lv_for_csv = lv.clone();
970 header = header.child(
971 div()
972 .id(SharedString::from(format!("list-csv-{mod_idx}")))
973 .px(px(10.))
974 .py(px(4.))
975 .bg(action_bg)
976 .text_color(accent)
977 .text_size(px(11.))
978 .rounded(px(3.))
979 .hover(move |d| d.bg(action_hover))
980 .child("⬇ CSV")
981 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
982 this.export_csv(&lv_for_csv, cx);
983 })),
984 );
985 }
986 main = main.child(header);
987
988 // Caja de búsqueda (sólo si la lista declara `search_in`).
989 if let Some(search) = &self.list_search {
990 main = main.child(
991 div()
992 .mb(px(8.))
993 .flex()
994 .flex_row()
995 .items_center()
996 .gap(px(6.))
997 .child(div().text_color(text_dim).text_size(px(12.)).child("🔍"))
998 .child(search.clone()),
999 );
1000 }
1001
1002 let row_detail = lv.row_detail.clone();
1003
1004 // Filas: buscar + ordenar (helper compartido con el export CSV),
1005 // luego paginar.
1006 let all_rows = self.list_filtered_sorted(lv, cx);
1007 let has_query = self
1008 .list_search
1009 .as_ref()
1010 .map(|i| !i.read(cx).text().trim().is_empty())
1011 .unwrap_or(false);
1012 let total = all_rows.len();
1013 let page_count = total.div_ceil(PAGE_SIZE).max(1);
1014 let page = self.list_page.min(page_count - 1);
1015 let start = page * PAGE_SIZE;
1016 let end = (start + PAGE_SIZE).min(total);
1017 let rows: Vec<(Uuid, Value)> = all_rows[start..end].to_vec();
1018
1019 let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
1020 let mut col_header = div()
1021 .flex()
1022 .flex_row()
1023 .py(px(6.))
1024 .border_b_1()
1025 .border_color(border)
1026 .text_color(text_dim)
1027 .text_size(px(11.));
1028 for c in &lv.columns {
1029 let frac = c.weight / total_weight;
1030 // Indicador del orden activo + clic en el header para ordenar.
1031 let arrow = match &self.list_sort {
1032 Some((f, asc)) if f == &c.field => {
1033 if *asc {
1034 " ▲"
1035 } else {
1036 " ▼"
1037 }
1038 }
1039 _ => "",
1040 };
1041 let field = c.field.clone();
1042 col_header = col_header.child(
1043 div()
1044 .id(SharedString::from(format!("col-{mod_idx}-{}", c.field)))
1045 .flex_grow()
1046 .flex_basis(px(100. * frac))
1047 .hover(move |d| d.bg(action_hover))
1048 .child(format!("{}{arrow}", c.label))
1049 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1050 this.toggle_sort(&field, cx);
1051 })),
1052 );
1053 }
1054 col_header = col_header
1055 .child(div().w(px(80.)).text_color(text_dim).child("id"))
1056 .child(div().w(px(100.)).text_color(text_dim).child("acciones"));
1057 main = main.child(col_header);
1058
1059 let entity_name = lv.entity.clone();
1060 for (id, value) in &rows {
1061 let id_copy = *id;
1062 let entity_for_edit = entity_name.clone();
1063 let entity_for_delete = entity_name.clone();
1064 let mut row = div()
1065 .flex()
1066 .flex_row()
1067 .py(px(6.))
1068 .border_b_1()
1069 .border_color(row_separator)
1070 .text_color(text)
1071 .text_size(px(12.));
1072 for c in &lv.columns {
1073 let frac = c.weight / total_weight;
1074 let v = lookup_field(value, &c.field);
1075 row = row.child(
1076 div()
1077 .flex_grow()
1078 .flex_basis(px(100. * frac))
1079 .child(self.render_cell(c, v)),
1080 );
1081 }
1082 row = row.child(
1083 div()
1084 .w(px(80.))
1085 .text_color(text_dim)
1086 .text_size(px(11.))
1087 .child(short_uuid(id)),
1088 );
1089 // Acciones por fila: 👁 ver (si hay row_detail) + ✎ + ✕.
1090 let mut actions = div().w(px(100.)).flex().flex_row().gap(px(4.));
1091 if let Some(detail_view) = &row_detail {
1092 let dv_key = detail_view.clone();
1093 actions = actions.child(
1094 div()
1095 .id(SharedString::from(format!("row-view-{mod_idx}-{id_copy}")))
1096 .px(px(6.))
1097 .text_color(text_dim)
1098 .text_size(px(13.))
1099 .hover(move |d| d.bg(action_hover))
1100 .child("👁")
1101 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1102 this.select_detail(mod_idx, dv_key.clone(), id_copy, cx);
1103 })),
1104 );
1105 }
1106 row = row.child(
1107 actions
1108 .child(
1109 div()
1110 .id(SharedString::from(format!("row-edit-{mod_idx}-{id_copy}")))
1111 .px(px(6.))
1112 .text_color(accent)
1113 .text_size(px(13.))
1114 .hover(move |d| d.bg(action_hover))
1115 .child("✎")
1116 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1117 this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx);
1118 })),
1119 )
1120 .child(
1121 div()
1122 .id(SharedString::from(format!("row-del-{mod_idx}-{id_copy}")))
1123 .px(px(6.))
1124 .text_color(destructive_fg)
1125 .text_size(px(13.))
1126 .hover(move |d| d.bg(destructive_hover))
1127 .child("✕")
1128 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1129 // Marca para borrar en lugar de borrar
1130 // directo: el modal de confirmación se
1131 // renderea arriba en `render` y maneja
1132 // confirm/cancel.
1133 this.pending_delete = Some((entity_for_delete.clone(), id_copy));
1134 this.toast = None;
1135 cx.notify();
1136 })),
1137 ),
1138 );
1139 main = main.child(row);
1140 }
1141
1142 if total == 0 {
1143 let msg = if has_query {
1144 "(sin resultados para la búsqueda)".to_string()
1145 } else {
1146 format!("(sin {})", lv.entity)
1147 };
1148 main = main.child(
1149 div()
1150 .py(px(12.))
1151 .text_color(text_dim)
1152 .text_size(px(12.))
1153 .child(msg),
1154 );
1155 } else {
1156 let mut footer = div()
1157 .mt(px(8.))
1158 .flex()
1159 .flex_row()
1160 .items_center()
1161 .gap(px(8.))
1162 .text_color(text_dim)
1163 .text_size(px(11.));
1164 if page_count > 1 {
1165 let last_page = page_count - 1;
1166 footer = footer.child(
1167 div()
1168 .id("list-prev")
1169 .px(px(8.))
1170 .py(px(2.))
1171 .bg(action_bg)
1172 .rounded(px(3.))
1173 .text_color(if page == 0 { text_dim } else { accent })
1174 .hover(move |d| d.bg(action_hover))
1175 .child("◀")
1176 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1177 this.list_page = this.list_page.saturating_sub(1);
1178 cx.notify();
1179 })),
1180 );
1181 footer = footer.child(div().child(format!("página {}/{}", page + 1, page_count)));
1182 footer = footer.child(
1183 div()
1184 .id("list-next")
1185 .px(px(8.))
1186 .py(px(2.))
1187 .bg(action_bg)
1188 .rounded(px(3.))
1189 .text_color(if page >= last_page { text_dim } else { accent })
1190 .hover(move |d| d.bg(action_hover))
1191 .child("▶")
1192 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1193 this.list_page = (this.list_page + 1).min(last_page);
1194 cx.notify();
1195 })),
1196 );
1197 }
1198 footer = footer.child(div().child(format!("{total} fila(s)")));
1199 main = main.child(footer);
1200 }
1201
1202 main
1203 }
1204
1205 /// Renderea el selector clickable de records existentes para un
1206 /// FieldSpec con kind=EntityRef. Lista compacta debajo del input;
1207 /// click en una opción setea el TextInput del field con el UUID
1208 /// seleccionado. El item del UUID actualmente seleccionado (si
1209 /// hay) se resalta con accent color.
1210 /// Renderea una ficha de detalle: campos del record + listas de
1211 /// records relacionados (back-references) + botones Volver/Editar.
1212 #[allow(clippy::too_many_arguments)]
1213 fn render_detail(
1214 &self,
1215 cx: &mut Context<Self>,
1216 mut main: gpui::Div,
1217 dv: &DetailView,
1218 mod_idx: usize,
1219 border: gpui::Hsla,
1220 text: gpui::Hsla,
1221 text_dim: gpui::Hsla,
1222 accent: gpui::Hsla,
1223 ) -> gpui::Div {
1224 let theme = Theme::global(cx);
1225 let action_bg = theme.bg_button();
1226 let action_hover = theme.bg_button_hover();
1227 let row_separator = theme.bg_row_active;
1228
1229 let back_view = self.detail_return.clone();
1230 let mut header = div()
1231 .flex()
1232 .flex_row()
1233 .items_center()
1234 .gap(px(10.))
1235 .mb(px(12.))
1236 .child(
1237 div()
1238 .text_color(text)
1239 .text_size(px(18.))
1240 .flex_grow()
1241 .child(dv.title.clone()),
1242 )
1243 .child(
1244 div()
1245 .id("detail-back")
1246 .px(px(10.))
1247 .py(px(4.))
1248 .bg(action_bg)
1249 .text_color(accent)
1250 .text_size(px(11.))
1251 .rounded(px(3.))
1252 .hover(move |d| d.bg(action_hover))
1253 .child("← Volver")
1254 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1255 match back_view.clone() {
1256 Some(v) => this.select_view(mod_idx, v, cx),
1257 None => {
1258 if let Some(first) = this
1259 .modules
1260 .get(mod_idx)
1261 .and_then(|m| m.menu.first().map(|i| i.view.clone()))
1262 {
1263 this.select_view(mod_idx, first, cx);
1264 }
1265 }
1266 }
1267 })),
1268 );
1269
1270 let Some(target_id) = self.detail_target else {
1271 return main.child(header).child(
1272 div()
1273 .text_color(text_dim)
1274 .child("Ningún record seleccionado."),
1275 );
1276 };
1277
1278 let entity_for_edit = dv.entity.clone();
1279 header = header.child(
1280 div()
1281 .id("detail-edit")
1282 .px(px(10.))
1283 .py(px(4.))
1284 .bg(action_bg)
1285 .text_color(accent)
1286 .text_size(px(11.))
1287 .rounded(px(3.))
1288 .hover(move |d| d.bg(action_hover))
1289 .child("✎ Editar")
1290 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1291 this.open_edit(mod_idx, entity_for_edit.clone(), target_id, cx);
1292 })),
1293 );
1294 main = main.child(header);
1295
1296 let Some(record) = self.backend.load_record(&dv.entity, target_id) else {
1297 return main.child(div().text_color(text_dim).child(format!(
1298 "El record {} ya no existe.",
1299 short_uuid(&target_id)
1300 )));
1301 };
1302
1303 // Campos del record.
1304 let mut fields_box = div().flex().flex_col();
1305 for c in &dv.fields {
1306 let v = lookup_field(&record, &c.field);
1307 fields_box = fields_box.child(
1308 div()
1309 .flex()
1310 .flex_row()
1311 .gap(px(12.))
1312 .py(px(4.))
1313 .border_b_1()
1314 .border_color(row_separator)
1315 .child(
1316 div()
1317 .w(px(160.))
1318 .flex_none()
1319 .text_color(text_dim)
1320 .text_size(px(12.))
1321 .child(c.label.clone()),
1322 )
1323 .child(
1324 div()
1325 .flex_grow()
1326 .text_color(text)
1327 .text_size(px(12.))
1328 .child(self.render_cell(c, v)),
1329 ),
1330 );
1331 }
1332 main = main.child(fields_box);
1333
1334 // Listas de records relacionados.
1335 for rl in &dv.related {
1336 main = main.child(self.render_related(rl, target_id, border, text, text_dim));
1337 }
1338 main
1339 }
1340
1341 /// Renderea una lista de records relacionados (back-reference):
1342 /// los records de `rl.entity` cuyo `rl.via_field` apunta al record
1343 /// `target_id`.
1344 fn render_related(
1345 &self,
1346 rl: &RelatedList,
1347 target_id: Uuid,
1348 border: gpui::Hsla,
1349 text: gpui::Hsla,
1350 text_dim: gpui::Hsla,
1351 ) -> gpui::Div {
1352 let id_str = target_id.to_string();
1353 let rows: Vec<(Uuid, Value)> = self
1354 .backend
1355 .list_records(&rl.entity)
1356 .into_iter()
1357 .filter(|(_, v)| v.get(&rl.via_field).and_then(Value::as_str) == Some(id_str.as_str()))
1358 .collect();
1359
1360 let mut section = div().flex().flex_col().mt(px(18.)).child(
1361 div()
1362 .text_color(text)
1363 .text_size(px(13.))
1364 .mb(px(4.))
1365 .child(format!("{} ({})", rl.title, rows.len())),
1366 );
1367
1368 if rows.is_empty() {
1369 return section.child(
1370 div()
1371 .py(px(4.))
1372 .text_color(text_dim)
1373 .text_size(px(11.))
1374 .child("(ninguno)"),
1375 );
1376 }
1377
1378 let total_weight: f32 = rl.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
1379 let mut head = div()
1380 .flex()
1381 .flex_row()
1382 .py(px(4.))
1383 .border_b_1()
1384 .border_color(border)
1385 .text_color(text_dim)
1386 .text_size(px(10.));
1387 for c in &rl.columns {
1388 head = head.child(
1389 div()
1390 .flex_grow()
1391 .flex_basis(px(100. * c.weight / total_weight))
1392 .child(c.label.clone()),
1393 );
1394 }
1395 section = section.child(head);
1396
1397 for (_, v) in &rows {
1398 let mut row = div()
1399 .flex()
1400 .flex_row()
1401 .py(px(3.))
1402 .text_color(text)
1403 .text_size(px(11.));
1404 for c in &rl.columns {
1405 row = row.child(
1406 div()
1407 .flex_grow()
1408 .flex_basis(px(100. * c.weight / total_weight))
1409 .child(self.render_cell(c, lookup_field(v, &c.field))),
1410 );
1411 }
1412 section = section.child(row);
1413 }
1414 section
1415 }
1416
1417 /// Renderea un tablero: una grilla de tarjetas de KPI, cada una con
1418 /// su agregado computado sobre los records de su entity.
1419 #[allow(clippy::too_many_arguments)]
1420 fn render_dashboard(
1421 &self,
1422 cx: &mut Context<Self>,
1423 mut main: gpui::Div,
1424 dv: &DashboardView,
1425 border: gpui::Hsla,
1426 text: gpui::Hsla,
1427 text_dim: gpui::Hsla,
1428 accent: gpui::Hsla,
1429 ) -> gpui::Div {
1430 let card_bg = Theme::global(cx).bg_panel_alt;
1431 main = main.child(
1432 div()
1433 .text_color(text)
1434 .text_size(px(18.))
1435 .mb(px(12.))
1436 .child(dv.title.clone()),
1437 );
1438
1439 let mut grid = div().flex().flex_row().flex_wrap().gap(px(12.));
1440 for card in &dv.cards {
1441 let records = self.backend.list_records(&card.entity);
1442 let result = compute_metric(&card.metric, card.filter.as_ref(), &records);
1443 let mut card_box = div()
1444 .flex()
1445 .flex_col()
1446 .gap(px(6.))
1447 .p(px(14.))
1448 .min_w(px(190.))
1449 .bg(card_bg)
1450 .border_1()
1451 .border_color(border)
1452 .rounded(px(8.))
1453 .child(
1454 div()
1455 .text_color(text_dim)
1456 .text_size(px(11.))
1457 .child(card.label.clone()),
1458 );
1459 match result {
1460 MetricResult::Scalar(s) => {
1461 // Entero si no tiene parte decimal — `Count` y sumas
1462 // de enteros se ven sin `.00`.
1463 let value = if s.fract() == 0.0 {
1464 Value::from(s as i64)
1465 } else {
1466 Value::from(s)
1467 };
1468 card_box = card_box.child(
1469 div()
1470 .text_color(accent)
1471 .text_size(px(26.))
1472 .child(format_value(Some(&value), &card.format)),
1473 );
1474 }
1475 MetricResult::Breakdown(rows) => {
1476 if rows.is_empty() {
1477 card_box = card_box.child(
1478 div()
1479 .text_color(text_dim)
1480 .text_size(px(11.))
1481 .child("(sin datos)"),
1482 );
1483 }
1484 let max = rows.iter().map(|(_, n)| *n).max().unwrap_or(1).max(1);
1485 for (key, n) in rows {
1486 let bar = "█".repeat((n * 12 / max).max(1));
1487 card_box = card_box.child(
1488 div()
1489 .flex()
1490 .flex_row()
1491 .items_center()
1492 .gap(px(6.))
1493 .text_size(px(11.))
1494 .child(div().w(px(96.)).flex_none().text_color(text).child(key))
1495 .child(div().flex_grow().text_color(accent).child(bar))
1496 .child(div().text_color(text_dim).child(n.to_string())),
1497 );
1498 }
1499 }
1500 }
1501 grid = grid.child(card_box);
1502 }
1503 main.child(grid)
1504 }
1505
1506 /// Render del valor de una celda de lista. Una columna con
1507 /// `ref_entity` resuelve su UUID al label del record referido; el
1508 /// resto aplica el `ValueFormat` declarado en la columna.
1509 fn render_cell(&self, c: &Column, v: Option<&Value>) -> String {
1510 if let Some(ref_entity) = &c.ref_entity {
1511 return match v {
1512 Some(Value::String(s)) => match Uuid::parse_str(s) {
1513 Ok(uuid) => self
1514 .backend
1515 .load_record(ref_entity, uuid)
1516 .map(|rec| human_label_for_record(&rec, &uuid))
1517 .unwrap_or_else(|| format!("(borrado · {})", short_uuid(&uuid))),
1518 Err(_) => render_value(v),
1519 },
1520 _ => render_value(v),
1521 };
1522 }
1523 format_value(v, &c.format)
1524 }
1525
1526 /// Label legible del record referenciado por un campo EntityRef.
1527 /// `(sin seleccionar)` si el campo está vacío.
1528 fn ref_label(&self, target: &str, current: &str) -> String {
1529 let current = current.trim();
1530 if current.is_empty() {
1531 return "(sin seleccionar)".to_string();
1532 }
1533 match Uuid::parse_str(current) {
1534 Ok(uuid) => self
1535 .backend
1536 .load_record(target, uuid)
1537 .map(|rec| human_label_for_record(&rec, &uuid))
1538 .unwrap_or_else(|| format!("(borrado · {})", short_uuid(&uuid))),
1539 Err(_) => current.to_string(),
1540 }
1541 }
1542
1543 /// Chips clickables para un campo [`FieldKind::Select`]. El chip de
1544 /// la opción elegida se resalta con accent. Click setea el
1545 /// `TextInput` del field (de donde lee el submit), igual que el
1546 /// selector de EntityRef.
1547 fn render_select_chips(
1548 &self,
1549 cx: &mut Context<Self>,
1550 field_name: String,
1551 options: &[SelectOption],
1552 text_dim: gpui::Hsla,
1553 accent: gpui::Hsla,
1554 ) -> gpui::Div {
1555 let theme = Theme::global(cx);
1556 let row_active = theme.bg_row_active;
1557 let row_hover = theme.bg_row_hover;
1558 let border = theme.border;
1559 let current = self
1560 .form_inputs
1561 .get(&field_name)
1562 .map(|inp| inp.read(&*cx).text().to_string())
1563 .unwrap_or_default();
1564
1565 let mut row = div().mt(px(2.)).flex().flex_row().flex_wrap().gap(px(4.));
1566
1567 for opt in options {
1568 let value = opt.value.clone();
1569 let is_selected = current == value;
1570 let field_for_click = field_name.clone();
1571 let value_for_click = value.clone();
1572 row = row.child(
1573 div()
1574 .id(SharedString::from(format!("select-{field_name}-{value}")))
1575 .px(px(8.))
1576 .py(px(3.))
1577 .rounded(px(10.))
1578 .border_1()
1579 .border_color(if is_selected { accent } else { border })
1580 .text_size(px(11.))
1581 .text_color(if is_selected { accent } else { text_dim })
1582 .when(is_selected, move |d| d.bg(row_active))
1583 .hover(move |d| d.bg(row_hover))
1584 .child(opt.display().to_string())
1585 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1586 if let Some(input) = this.form_inputs.get(&field_for_click) {
1587 input.update(cx, |inp, cx| inp.set_text(value_for_click.clone(), cx));
1588 }
1589 cx.notify();
1590 })),
1591 );
1592 }
1593 row
1594 }
1595
1596 #[allow(clippy::too_many_arguments)]
1597 fn render_entity_ref_selector(
1598 &self,
1599 cx: &mut Context<Self>,
1600 field_name: String,
1601 target_entity: String,
1602 text: gpui::Hsla,
1603 text_dim: gpui::Hsla,
1604 accent: gpui::Hsla,
1605 ) -> gpui::Div {
1606 let _ = text;
1607 // Slots ornament para hover/selected del selector + border.
1608 let theme = Theme::global(cx);
1609 let row_active = theme.bg_row_active;
1610 let row_hover = theme.bg_row_hover;
1611 let rows = self.list_rows(&target_entity);
1612 let current = self
1613 .form_inputs
1614 .get(&field_name)
1615 .map(|inp| inp.read(&*cx).text().to_string())
1616 .unwrap_or_default();
1617
1618 let mut container = div()
1619 .mt(px(4.))
1620 .pl(px(8.))
1621 .border_l_2()
1622 .border_color(theme.border)
1623 .flex()
1624 .flex_col()
1625 .gap(px(2.));
1626
1627 if rows.is_empty() {
1628 return container.child(
1629 div()
1630 .px(px(6.))
1631 .py(px(4.))
1632 .text_color(text_dim)
1633 .text_size(px(10.))
1634 .child(format!(
1635 "(sin {target_entity}: creá uno antes para referenciar)"
1636 )),
1637 );
1638 }
1639
1640 container = container.child(
1641 div()
1642 .text_color(text_dim)
1643 .text_size(px(10.))
1644 .child(format!("Seleccioná un {target_entity}:")),
1645 );
1646
1647 for (id, value) in &rows {
1648 let label = human_label_for_record(value, id);
1649 let id_str = id.to_string();
1650 let is_selected = current == id_str;
1651 let field_for_click = field_name.clone();
1652 let id_for_click = id_str.clone();
1653 container = container.child(
1654 div()
1655 .id(SharedString::from(format!(
1656 "entity-ref-{field_name}-{id_str}"
1657 )))
1658 .px(px(6.))
1659 .py(px(2.))
1660 .text_size(px(11.))
1661 .text_color(if is_selected { accent } else { text_dim })
1662 .when(is_selected, move |d| d.bg(row_active))
1663 .hover(move |d| d.bg(row_hover))
1664 .child(label)
1665 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1666 if let Some(input) = this.form_inputs.get(&field_for_click) {
1667 input.update(cx, |inp, cx| inp.set_text(id_for_click.clone(), cx));
1668 }
1669 cx.notify();
1670 })),
1671 );
1672 }
1673 container
1674 }
1675
1676 #[allow(clippy::too_many_arguments)]
1677 fn render_form(
1678 &self,
1679 cx: &mut Context<Self>,
1680 mut main: gpui::Div,
1681 fv: &FormView,
1682 mod_idx: usize,
1683 _border: gpui::Hsla,
1684 text: gpui::Hsla,
1685 text_dim: gpui::Hsla,
1686 accent: gpui::Hsla,
1687 ) -> gpui::Div {
1688 // Slots ornament para el botón submit + bg de fallback inputs.
1689 let theme = Theme::global(cx);
1690 let submit_bg = theme.bg_button();
1691 let submit_hover = theme.bg_button_hover();
1692 let input_bg = theme.bg_input();
1693 // En modo edit, el título refleja eso para que el user no
1694 // se confunda creyendo que hace alta nueva.
1695 let title = match self.editing.as_ref() {
1696 Some((e, id)) if e == &fv.entity => {
1697 format!("Editar {} {}", fv.entity, short_uuid(id))
1698 }
1699 _ => fv.title.clone(),
1700 };
1701 main = main.child(
1702 div()
1703 .text_color(text)
1704 .text_size(px(18.))
1705 .mb(px(12.))
1706 .child(title),
1707 );
1708 for f in &fv.fields {
1709 let label = if f.required {
1710 format!("{} *", f.label)
1711 } else {
1712 f.label.clone()
1713 };
1714
1715 let mut field_box = div().flex().flex_col().mb(px(10.)).child(
1716 div()
1717 .text_color(text_dim)
1718 .text_size(px(11.))
1719 .mb(px(2.))
1720 .child(label),
1721 );
1722
1723 // Render según el kind. AutoId y Select NO montan el
1724 // TextInput editable —aunque sí existe en `form_inputs`, de
1725 // donde lee el submit—; presentan su propio control.
1726 match f.kind {
1727 FieldKind::AutoId => {
1728 // Read-only: el UUID autogenerado, sólo informativo.
1729 let val = self
1730 .form_inputs
1731 .get(&f.name)
1732 .map(|i| i.read(&*cx).text().to_string())
1733 .unwrap_or_default();
1734 field_box = field_box.child(
1735 div()
1736 .px(px(8.))
1737 .py(px(6.))
1738 .bg(input_bg)
1739 .text_color(text_dim)
1740 .text_size(px(11.))
1741 .child(format!("{val} · automático")),
1742 );
1743 }
1744 FieldKind::Select => {
1745 field_box = field_box.child(self.render_select_chips(
1746 cx,
1747 f.name.clone(),
1748 &f.options,
1749 text_dim,
1750 accent,
1751 ));
1752 }
1753 FieldKind::EntityRef => {
1754 // Display read-only del record elegido (label, no
1755 // el UUID crudo) + selector clickable debajo. El
1756 // TextInput vive en `form_inputs` pero no se monta.
1757 if let Some(target) = &f.ref_entity {
1758 let current = self
1759 .form_inputs
1760 .get(&f.name)
1761 .map(|i| i.read(&*cx).text().to_string())
1762 .unwrap_or_default();
1763 let is_empty = current.trim().is_empty();
1764 field_box = field_box.child(
1765 div()
1766 .px(px(8.))
1767 .py(px(6.))
1768 .bg(input_bg)
1769 .text_color(if is_empty { text_dim } else { text })
1770 .text_size(px(11.))
1771 .child(self.ref_label(target, ¤t)),
1772 );
1773 field_box = field_box.child(self.render_entity_ref_selector(
1774 cx,
1775 f.name.clone(),
1776 target.clone(),
1777 text,
1778 text_dim,
1779 accent,
1780 ));
1781 }
1782 // Sin ref_entity es imposible: Module::validate lo
1783 // rechaza al cargar el módulo.
1784 }
1785 _ => {
1786 // Mount del TextInput vivo (creado en select_view).
1787 if let Some(input) = self.form_inputs.get(&f.name) {
1788 field_box = field_box.child(input.clone());
1789 } else {
1790 // No debería pasar — select_view crea inputs por
1791 // cada field. Fallback estático por seguridad.
1792 field_box = field_box.child(
1793 div()
1794 .px(px(8.))
1795 .py(px(6.))
1796 .bg(input_bg)
1797 .text_color(text_dim)
1798 .child("(input no inicializado)"),
1799 );
1800 }
1801 }
1802 }
1803
1804 if let Some(help) = &f.help {
1805 field_box = field_box.child(
1806 div()
1807 .mt(px(2.))
1808 .text_color(text_dim)
1809 .text_size(px(10.))
1810 .child(help.clone()),
1811 );
1812 }
1813 main = main.child(field_box);
1814 }
1815
1816 let editing_this = matches!(
1817 self.editing.as_ref(),
1818 Some((e, _)) if e == &fv.entity
1819 );
1820 let submit_label = match &fv.on_submit {
1821 Action::SeedEntity { entity, .. } => {
1822 if editing_this {
1823 format!("Guardar cambios en {entity}")
1824 } else {
1825 format!("Crear {entity}")
1826 }
1827 }
1828 Action::Morphism { name, .. } => format!("Ejecutar {name}"),
1829 Action::OpenView { label, view } => {
1830 label.clone().unwrap_or_else(|| format!("Ir a {view}"))
1831 }
1832 };
1833 let submit_action = fv.on_submit.clone();
1834 main = main.child(
1835 div().mt(px(12.)).child(
1836 div()
1837 .id(SharedString::from(format!("form-submit-{mod_idx}")))
1838 .px(px(12.))
1839 .py(px(6.))
1840 .bg(submit_bg)
1841 .text_color(accent)
1842 .text_size(px(12.))
1843 .rounded(px(3.))
1844 .hover(move |d| d.bg(submit_hover))
1845 .child(submit_label)
1846 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1847 this.apply_action(submit_action.clone(), cx);
1848 })),
1849 ),
1850 );
1851
1852 main = main.child(
1853 div()
1854 .mt(px(20.))
1855 .text_color(text_dim)
1856 .text_size(px(10.))
1857 .child(
1858 "Tip: click en el campo para enfocar; Enter no envía (todavía), \
1859 usá el botón. Backspace borra el último carácter.",
1860 ),
1861 );
1862 main
1863 }
1864}
1865
1866#[cfg(test)]
1867mod tests {
1868 //! Tests del widget. Funciones puras (sin GPUI cx) sólo. Los
1869 //! tests de backend impl viven en el binario que provee el
1870 //! backend (ej: nakui-ui). Los helpers movidos a
1871 //! nahual-meta-runtime se testean allí.
1872 use super::*;
1873 use serde_json::json;
1874
1875 #[test]
1876 fn lookup_field_simple_and_nested() {
1877 let v = json!({
1878 "name": "Acme",
1879 "address": { "city": "Bogotá", "country": "CO" }
1880 });
1881 assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
1882 assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá"));
1883 assert!(lookup_field(&v, "missing").is_none());
1884 assert!(lookup_field(&v, "address.zipcode").is_none());
1885 }
1886
1887 #[test]
1888 fn next_sort_cycles_asc_desc_off() {
1889 // Sin orden → ascendente.
1890 let s = next_sort(None, "monto");
1891 assert_eq!(s, Some(("monto".to_string(), true)));
1892 // Misma columna → descendente.
1893 let s = next_sort(s, "monto");
1894 assert_eq!(s, Some(("monto".to_string(), false)));
1895 // Misma columna otra vez → sin orden.
1896 let s = next_sort(s, "monto");
1897 assert_eq!(s, None);
1898 // Otra columna siempre arranca ascendente.
1899 let s = next_sort(Some(("monto".to_string(), false)), "etapa");
1900 assert_eq!(s, Some(("etapa".to_string(), true)));
1901 }
1902
1903 #[test]
1904 fn append_compact_msg_handles_both_branches() {
1905 // Sin compact: base solo, sin separador.
1906 let s = append_compact_msg("creado X".into(), None);
1907 assert_eq!(s.as_ref(), "creado X");
1908 // Con compact: concatena con "; ".
1909 let s = append_compact_msg(
1910 "creado X".into(),
1911 Some("auto-compact: snapshot @ seq 49".into()),
1912 );
1913 assert_eq!(s.as_ref(), "creado X; auto-compact: snapshot @ seq 49");
1914 }
1915
1916 #[test]
1917 fn format_seed_toast_distinguishes_create_update_no_change() {
1918 let id = Uuid::new_v4();
1919 let outcome_create = WriteOutcome {
1920 id: Some(id),
1921 changed: 1,
1922 post_status: None,
1923 };
1924 let toast = format_seed_toast("Customer", false, &outcome_create);
1925 assert!(toast.starts_with("creado Customer"));
1926
1927 let outcome_update = WriteOutcome {
1928 id: Some(id),
1929 changed: 3,
1930 post_status: None,
1931 };
1932 let toast = format_seed_toast("Customer", true, &outcome_update);
1933 assert!(toast.starts_with("actualizado Customer"));
1934 assert!(toast.contains("(3 campo(s))"));
1935
1936 let outcome_no_change = WriteOutcome::no_change(id);
1937 let toast = format_seed_toast("Customer", true, &outcome_no_change);
1938 assert!(toast.contains("sin cambios"));
1939 }
1940}