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