gioser

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Sutichaykuna

⎇ c56ef2554615e03b5fcdfb80c0e5e5c826a94f00 crates/modules/nahual/widgets/meta-form/src/lib.rs Rust · 2032 siq'ikuna · 78.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(nakui): Fase 7 del ERP — pulido (cierra el plan maestro) c56ef2554 · sergio ·

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, &params, 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(&current, &obj);
512            let clear_fields = compute_clear_fields(&current, &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, &current)),
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}