gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definitions

⎇ eba629a806c7f861dd00a37c20b0f9e9d1ef3a52 crates/modules/nahual/widgets/meta-form/src/lib.rs Rust · 1354 lines · 52.8 KB History Who touched what raw

feat(nakui): Fase 2 del ERP — relaciones legibles + formato eba629a80 · 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    compute_clear_fields, compute_field_delta, format_value, human_label_for_record,
31    parse_field_value, render_value, resolve_param_value, short_uuid, validate_entity_refs,
32    value_to_input_text, MetaBackend, WriteOutcome,
33};
34use nahual_meta_schema::{
35    Action, Column, FieldKind, FieldSpec, FormView, ListView, Module, SelectOption, View,
36};
37use nahual_theme::Theme;
38use nahual_widget_banner::{banner_themed, themed_colors, Banner};
39use nahual_widget_text_input::TextInput;
40use nahual_widget_theme_switcher::theme_switcher;
41use serde_json::Value;
42use uuid::Uuid;
43
44/// Estado del runtime de UI. Toda la persistencia/ejecución está
45/// detrás del trait [`MetaBackend`]; este struct sólo conoce GPUI
46/// state y el schema de los módulos.
47///
48/// Genérico sobre el backend `B`. Un binario decide qué backend
49/// proveer (Nakui via `NakuiBackend`, mock para tests, etc.).
50pub struct MetaApp<B: MetaBackend> {
51    /// Módulos cargados, ordenados por id.
52    modules: Vec<Module>,
53    /// Backend que ejecuta seed/update/delete/morphism.
54    backend: B,
55    /// (módulo idx, vista key) actualmente activos.
56    active: Option<(usize, String)>,
57    /// Inputs vivos para el form actual: nombre del campo → TextInput.
58    /// Se reemplaza al cambiar de vista (drop de los anteriores).
59    form_inputs: BTreeMap<String, Entity<TextInput>>,
60    /// Si está set, el próximo render del Form pre-llena los inputs
61    /// con los valores del record indicado, y `commit_seed` emite
62    /// un `update` (no un seed nuevo). Limpia al cambiar de view o
63    /// tras submit exitoso.
64    editing: Option<(String, Uuid)>,
65    /// Si está set, el banner modal de confirmación de delete está
66    /// activo: `(entity, id)` del record que el usuario marcó para
67    /// borrar. Permanece hasta que el usuario click [Confirmar]
68    /// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia).
69    /// Navegación a otra view también cancela.
70    pending_delete: Option<(String, Uuid)>,
71    /// Mensaje toast al pie (success de submit, error de carga, etc.).
72    toast: Option<SharedString>,
73    /// Si la carga de módulos falló al inicio.
74    load_error: Option<SharedString>,
75}
76
77impl<B: MetaBackend> MetaApp<B> {
78    /// Constructor del widget. El caller pre-construye sus módulos +
79    /// backend + cualquier mensaje inicial que quiera mostrar al
80    /// usuario al abrir (típicamente el toast del replay y el error
81    /// banner de cosas que fallaron de bootstrap).
82    ///
83    /// La active view default es la primera entry del menú del
84    /// primer módulo (orden lexicográfico por id).
85    pub fn new(
86        modules: Vec<Module>,
87        backend: B,
88        initial_toast: Option<String>,
89        initial_error: Option<String>,
90        _cx: &mut Context<Self>,
91    ) -> Self {
92        let active = modules
93            .first()
94            .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
95        Self {
96            modules,
97            backend,
98            active,
99            form_inputs: BTreeMap::new(),
100            editing: None,
101            pending_delete: None,
102            toast: initial_toast.map(SharedString::from),
103            load_error: initial_error.map(SharedString::from),
104        }
105    }
106
107    /// Cambia la vista activa. Si la nueva vista es un Form, crea
108    /// `TextInput` entities para cada field. Pre-llena con valores
109    /// del record si hay `editing` para esa entity; si no, usa el
110    /// `default` del schema.
111    /// Drop de los inputs anteriores ocurre al sobreescribir el map.
112    fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context<Self>) {
113        self.active = Some((mod_idx, view_key.clone()));
114        self.toast = None;
115        // Navegar a otra view cancela cualquier delete pendiente:
116        // el record marcado puede no estar visible en la nueva view.
117        self.pending_delete = None;
118        self.form_inputs = BTreeMap::new();
119        if let Some(module) = self.modules.get(mod_idx) {
120            if let Some(View::Form(form)) = module.views.get(&view_key) {
121                // Snapshot del record si estamos editando esta entity.
122                let editing_record: Option<Value> = self.editing.as_ref().and_then(|(e, id)| {
123                    if e == &form.entity {
124                        self.backend.load_record(e, *id)
125                    } else {
126                        None
127                    }
128                });
129                for f in &form.fields {
130                    let initial = if f.kind == FieldKind::AutoId {
131                        // Editando: conservar el id del record.
132                        // Alta: UUID nuevo, que el usuario no teclea.
133                        editing_record
134                            .as_ref()
135                            .and_then(|rec| rec.get(&f.name).map(value_to_input_text))
136                            .unwrap_or_else(|| Uuid::new_v4().to_string())
137                    } else if let Some(rec) = &editing_record {
138                        rec.get(&f.name)
139                            .map(value_to_input_text)
140                            .unwrap_or_else(|| f.default.clone().unwrap_or_default())
141                    } else {
142                        f.default.clone().unwrap_or_default()
143                    };
144                    let input = cx.new(|cx| TextInput::new(initial, cx));
145                    self.form_inputs.insert(f.name.clone(), input);
146                }
147            } else {
148                // Cambiar a una view que no es Form invalida el editing
149                // pendiente.
150                self.editing = None;
151            }
152        }
153        cx.notify();
154    }
155
156    /// Inicia un edit del record: setea `editing` y abre la primera
157    /// view de tipo Form del módulo (convención: la del schema).
158    fn open_edit(&mut self, mod_idx: usize, entity: String, id: Uuid, cx: &mut Context<Self>) {
159        self.editing = Some((entity.clone(), id));
160        let form_view_key = self.modules.get(mod_idx).and_then(|m| {
161            m.views.iter().find_map(|(key, v)| match v {
162                View::Form(form) if form.entity == entity => Some(key.clone()),
163                _ => None,
164            })
165        });
166        match form_view_key {
167            Some(key) => self.select_view(mod_idx, key, cx),
168            None => {
169                self.toast = Some(SharedString::from(format!(
170                    "no hay form view para entity '{entity}' en este módulo"
171                )));
172                self.editing = None;
173                cx.notify();
174            }
175        }
176    }
177
178    /// Borra un record. Emite Morphism con un FieldOp::Delete + lo
179    /// aplica al store via `apply` (no via remove directo, mantiene
180    /// el modelo de "todo cambio post-seed pasa por ops").
181    /// Borra un record vía `MetaBackend::delete`. Devuelve el outcome
182    /// del backend (incluye eventual `post_status` del compact tick).
183    fn commit_delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String> {
184        self.backend.delete(entity, id)
185    }
186
187    /// Aplica una acción (click en menú, botón de form, action de
188    /// list). Mutaciones contra el backend ocurren acá.
189    pub fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
190        let mod_idx = match self.active.as_ref() {
191            Some((i, _)) => *i,
192            None => return,
193        };
194        match action {
195            Action::OpenView { view, .. } => {
196                // Salir a otra view cancela el edit pendiente.
197                self.editing = None;
198                self.select_view(mod_idx, view, cx);
199            }
200            Action::SeedEntity { entity, next_view } => {
201                let was_editing = self.editing.is_some();
202                match self.commit_seed(mod_idx, &entity, cx) {
203                    Ok(outcome) => {
204                        let toast_msg = format_seed_toast(&entity, was_editing, &outcome);
205                        self.toast = Some(append_compact_msg(toast_msg, outcome.post_status));
206                        // Limpia editing tras un commit exitoso —
207                        // el record ya está sincronizado (incluso
208                        // un NoChange cierra el modo edit).
209                        self.editing = None;
210                        if let Some(v) = next_view {
211                            self.select_view(mod_idx, v, cx);
212                        } else {
213                            for input in self.form_inputs.values() {
214                                input.update(cx, |inp, cx| inp.set_text("", cx));
215                            }
216                        }
217                    }
218                    Err(e) => {
219                        self.toast = Some(SharedString::from(format!("error: {e}")));
220                    }
221                }
222                cx.notify();
223            }
224            Action::Morphism {
225                name,
226                inputs,
227                params,
228                next_view,
229            } => {
230                match self.commit_morphism(mod_idx, &name, &inputs, &params, cx) {
231                    Ok(outcome) => {
232                        let base =
233                            format!("morphism '{name}' OK ({} op(s) aplicadas)", outcome.changed);
234                        self.toast = Some(append_compact_msg(base, outcome.post_status));
235                        if let Some(v) = next_view {
236                            self.select_view(mod_idx, v, cx);
237                        }
238                    }
239                    Err(e) => {
240                        self.toast =
241                            Some(SharedString::from(format!("morphism '{name}' falló: {e}")));
242                    }
243                }
244                cx.notify();
245            }
246        }
247    }
248
249    /// Despacha un morphism via el backend. Resuelve inputs (UUIDs)
250    /// + params (Value object) leyendo los TextInput del form.
251    fn commit_morphism(
252        &mut self,
253        mod_idx: usize,
254        morphism: &str,
255        inputs_map: &BTreeMap<String, String>,
256        params_fields: &[String],
257        cx: &mut Context<Self>,
258    ) -> Result<WriteOutcome, String> {
259        let module = self
260            .modules
261            .get(mod_idx)
262            .ok_or_else(|| "módulo inválido".to_string())?;
263        let module_id = module.id.clone();
264
265        // Resolver inputs: por cada (role, field_name), parsear el
266        // value del input como Uuid.
267        let mut inputs: BTreeMap<String, Uuid> = BTreeMap::new();
268        for (role, field_name) in inputs_map {
269            let raw = self
270                .form_inputs
271                .get(field_name)
272                .map(|inp| inp.read(&*cx).text().to_string())
273                .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
274            let id = Uuid::parse_str(raw.trim()).map_err(|_| {
275                format!("input '{role}' (field '{field_name}'): '{raw}' no es UUID válido")
276            })?;
277            inputs.insert(role.clone(), id);
278        }
279
280        // Resolver params: si la lista está vacía, todos los fields
281        // del form que no estén en `inputs_map` van a params.
282        let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect();
283        let mut params_obj = serde_json::Map::new();
284        let field_iter: Vec<String> = if params_fields.is_empty() {
285            self.form_inputs
286                .keys()
287                .filter(|k| !input_field_set.contains(*k))
288                .cloned()
289                .collect()
290        } else {
291            params_fields.to_vec()
292        };
293
294        // FieldSpec del Form view activo para parseo estricto por kind.
295        let active_form_fields: Option<Vec<FieldSpec>> =
296            self.active.as_ref().and_then(|(_, vk)| {
297                module.views.get(vk).and_then(|v| match v {
298                    View::Form(f) => Some(f.fields.clone()),
299                    _ => None,
300                })
301            });
302
303        for field_name in field_iter {
304            let raw = self
305                .form_inputs
306                .get(&field_name)
307                .map(|inp| inp.read(&*cx).text().to_string())
308                .unwrap_or_default();
309            let spec = active_form_fields
310                .as_ref()
311                .and_then(|fs| fs.iter().find(|f| f.name == field_name));
312            let value = resolve_param_value(&field_name, &raw, spec)?;
313            params_obj.insert(field_name, value);
314        }
315
316        self.backend
317            .morphism(&module_id, morphism, inputs, Value::Object(params_obj))
318    }
319
320    /// Construye un payload desde los TextInput vivos y delega al
321    /// backend (`seed` para alta nueva, `update` con set+clear para
322    /// edit). Devuelve el `WriteOutcome` del backend (incluye
323    /// `changed` y `post_status` para el toast).
324    fn commit_seed(
325        &mut self,
326        mod_idx: usize,
327        entity: &str,
328        cx: &mut Context<Self>,
329    ) -> Result<WriteOutcome, String> {
330        let module = &self.modules[mod_idx];
331        let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
332            Some((_, view_key)) => match module.views.get(view_key) {
333                Some(View::Form(f)) => f.fields.clone(),
334                _ => return Err("la vista activa no es un Form".into()),
335            },
336            None => return Err("ninguna vista activa".into()),
337        };
338        let mut obj = serde_json::Map::new();
339        // Fields que el form deja vacíos y son optional: candidatos
340        // a Clear en el path de EDIT.
341        let mut to_clear: Vec<String> = Vec::new();
342        // EntityRef refs a validar tras parse loop (en una toma del
343        // backend en lugar de N).
344        let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new();
345        for f in &spec_fields {
346            let raw = self
347                .form_inputs
348                .get(&f.name)
349                .map(|input| input.read(cx).text().to_string())
350                .unwrap_or_default();
351            if f.required && raw.trim().is_empty() {
352                return Err(format!("campo '{}' es obligatorio", f.label));
353            }
354            if raw.is_empty() && !f.required {
355                to_clear.push(f.name.clone());
356                continue;
357            }
358            let value =
359                parse_field_value(f.kind, &raw).map_err(|e| format!("campo '{}': {e}", f.label))?;
360            if f.kind == FieldKind::EntityRef {
361                if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
362                    let id = Uuid::parse_str(uuid_str).expect("parse_field_value validated UUID");
363                    entity_refs.push((f.label.clone(), target.clone(), id));
364                }
365            }
366            obj.insert(f.name.clone(), value);
367        }
368        // Validar EntityRefs contra el backend actual. Cierre wrappea
369        // backend.load_record para mantener la firma de
370        // validate_entity_refs (que es store-agnóstica).
371        if !entity_refs.is_empty() {
372            let backend = &self.backend;
373            validate_entity_refs(|e, id| backend.load_record(e, id), &entity_refs)?;
374        }
375
376        // Ramificación: edit (hay editing para esta entity) vs alta.
377        let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
378
379        if let Some((_, id)) = editing_match {
380            // EDIT: cargar current, computar delta, llamar
381            // backend.update con set+clear pre-computados.
382            let current = self.backend.load_record(entity, id).unwrap_or(Value::Null);
383            let set_delta = compute_field_delta(&current, &obj);
384            let clear_fields = compute_clear_fields(&current, &to_clear);
385            self.backend.update(entity, id, set_delta, clear_fields)
386        } else {
387            // SEED: alta nueva — el backend genera el Uuid.
388            self.backend.seed(entity, obj)
389        }
390    }
391
392    /// Snapshot ordenado de records de una entity (proxy al backend).
393    fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
394        self.backend.list_records(entity)
395    }
396}
397
398/// Formatea el toast para la rama Action::SeedEntity según el
399/// `WriteOutcome` del backend. `was_editing` distingue "creado"
400/// vs "actualizado" — el WriteOutcome solo no alcanza porque
401/// `seed` y `update` ambos devuelven `id = Some(...)`.
402fn format_seed_toast(entity: &str, was_editing: bool, outcome: &WriteOutcome) -> String {
403    let id_short = outcome.id.map(|id| short_uuid(&id)).unwrap_or_default();
404    match (was_editing, outcome.changed) {
405        (false, _) => format!("creado {entity} {id_short}"),
406        (true, 0) => format!("{entity} {id_short} sin cambios — no log entry"),
407        (true, n) => format!("actualizado {entity} {id_short} ({n} campo(s))"),
408    }
409}
410
411/// Concatena el msg opcional del compact tick al toast del op
412/// original con `"; "` separator.
413fn append_compact_msg(base: String, compact_msg: Option<String>) -> SharedString {
414    match compact_msg {
415        Some(m) => SharedString::from(format!("{base}; {m}")),
416        None => SharedString::from(base),
417    }
418}
419
420/// Walker dentro de un `Value` por path con `.` como separador.
421/// Local porque sólo lo usa la lista renderer y no tiene tests
422/// dedicados afuera. Si crece su uso se puede mover a meta-runtime.
423fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
424    let mut cur = v;
425    for seg in path.split('.') {
426        cur = cur.get(seg)?;
427    }
428    Some(cur)
429}
430
431impl<B: MetaBackend> Render for MetaApp<B> {
432    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
433        // Paleta del chrome viene del Theme global. Derivamos los
434        // 7 slots que las funciones render_* usan; las firmas
435        // siguen tomando los colors individuales (Hsla / Background)
436        // para preservar el shape del API interno.
437        //
438        // Si el caller no instaló un Theme, `Theme::global` panicea.
439        // Convención: el binario shell instala el theme en main.
440        let theme = Theme::global(cx).clone();
441        let bg = theme.bg_app;
442        let panel = theme.bg_panel;
443        let border = theme.border;
444        let text = theme.fg_text;
445        let text_dim = theme.fg_muted;
446        let accent = theme.accent;
447        let accent_active = theme.accent_strong;
448
449        let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
450        let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
451        let confirm_banner = self.render_confirm_delete_banner(cx);
452        let toast_div = self
453            .toast
454            .as_ref()
455            .map(|t| banner_themed(cx, Banner::Success, t.clone()));
456        let error_banner = self
457            .load_error
458            .as_ref()
459            .map(|e| banner_themed(cx, Banner::Error, e.clone()));
460
461        div()
462            .flex()
463            .flex_col()
464            .size_full()
465            .bg(bg)
466            // Capture phase: el Esc llega al root ANTES que cualquier
467            // TextInput descendiente. Si hay un delete pendiente, lo
468            // cancelamos. Sin pending no hacemos nada (el evento sigue
469            // su flujo normal y el TextInput recibe el Esc bubble).
470            .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _w, cx| {
471                if event.keystroke.key != "escape" {
472                    return;
473                }
474                if let Some((entity, _id)) = this.pending_delete.take() {
475                    this.toast = Some(SharedString::from(format!(
476                        "delete cancelado ({entity}) [esc]"
477                    )));
478                    cx.notify();
479                }
480            }))
481            .when_some(error_banner, |d, b| d.child(b))
482            .when_some(confirm_banner, |d, b| d.child(b))
483            .child(
484                div()
485                    .flex()
486                    .flex_row()
487                    .flex_grow()
488                    .child(sidebar)
489                    .child(main_panel),
490            )
491            .when_some(toast_div, |d, t| d.child(t))
492    }
493}
494
495impl<B: MetaBackend> MetaApp<B> {
496    /// Renderea el banner modal de confirmación cuando hay un delete
497    /// pendiente. Devuelve `None` si no hay nada que confirmar.
498    ///
499    /// UX: banner amber prominente arriba de todo el contenido. No es
500    /// un overlay flotante (GPUI no expone z-index fácilmente sin
501    /// setup) — es un row del flex_col raíz, así que el contenido
502    /// debajo se desplaza unos pixels mientras está activo. Suficiente
503    /// para forzar al usuario a leer + click.
504    fn render_confirm_delete_banner(&self, cx: &mut Context<Self>) -> Option<gpui::Div> {
505        let (entity, id) = self.pending_delete.as_ref()?;
506        let entity_owned = entity.clone();
507        let id_owned = *id;
508        let entity_for_cancel = entity_owned.clone();
509        let id_short = short_uuid(&id_owned);
510        let entity_for_confirm = entity_owned.clone();
511
512        // Banner base usa los colors themed del kind Warning (amber
513        // que sigue is_dark del theme actual). Los buttons confirm
514        // (rojo) y cancel (gris) usan los colors themed del Error
515        // y un derivative del border respectivamente.
516        let theme = Theme::global(cx);
517        let (banner_bg, banner_text) = themed_colors(Banner::Warning, theme);
518        let (confirm_bg, confirm_text) = themed_colors(Banner::Error, theme);
519        let cancel_bg: gpui::Background = theme.bg_panel_alt;
520        let cancel_text = theme.fg_text;
521        // Hover colors capturados antes de las closures para que el
522        // move |d| d.bg(...) los cierre.
523        let cancel_hover = theme.bg_button_hover();
524        let confirm_hover = theme.bg_destructive_hover();
525        let hint_color = theme.fg_muted;
526
527        Some(
528            div()
529                .flex()
530                .flex_row()
531                .items_center()
532                .gap(px(12.))
533                .px(px(12.))
534                .py(px(8.))
535                .bg(banner_bg)
536                .text_color(banner_text)
537                .text_size(px(12.))
538                .child(
539                    div()
540                        .flex_grow()
541                        .flex()
542                        .flex_col()
543                        .child(format!("¿Borrar {entity_owned} {id_short}?"))
544                        .child(
545                            div()
546                                .text_size(px(10.))
547                                .text_color(hint_color)
548                                .child("Esc para cancelar · click [Confirmar] para borrar"),
549                        ),
550                )
551                .child(
552                    div()
553                        .id(SharedString::from(format!(
554                            "confirm-del-cancel-{}",
555                            id_owned
556                        )))
557                        .px(px(10.))
558                        .py(px(4.))
559                        .bg(cancel_bg)
560                        .text_color(cancel_text)
561                        .hover(move |d| d.bg(cancel_hover))
562                        .child("Cancelar")
563                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
564                            this.pending_delete = None;
565                            this.toast = Some(SharedString::from(format!(
566                                "delete cancelado ({entity_for_cancel})"
567                            )));
568                            cx.notify();
569                        })),
570                )
571                .child(
572                    div()
573                        .id(SharedString::from(format!("confirm-del-ok-{}", id_owned)))
574                        .px(px(10.))
575                        .py(px(4.))
576                        .bg(confirm_bg)
577                        .text_color(confirm_text)
578                        .hover(move |d| d.bg(confirm_hover))
579                        .child("Confirmar")
580                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
581                            // Limpiar primero para que un fallo del
582                            // commit no deje el banner colgado y el
583                            // toast tape el banner.
584                            this.pending_delete = None;
585                            match this.commit_delete(&entity_for_confirm, id_owned) {
586                                Ok(outcome) => {
587                                    let base = format!(
588                                        "borrado {entity_for_confirm} {}",
589                                        short_uuid(&id_owned)
590                                    );
591                                    this.toast =
592                                        Some(append_compact_msg(base, outcome.post_status));
593                                }
594                                Err(e) => {
595                                    this.toast =
596                                        Some(SharedString::from(format!("error borrando: {e}")));
597                                }
598                            }
599                            cx.notify();
600                        })),
601                ),
602        )
603    }
604
605    fn render_sidebar(
606        &self,
607        cx: &mut Context<Self>,
608        panel: gpui::Background,
609        border: gpui::Hsla,
610        text: gpui::Hsla,
611        text_dim: gpui::Hsla,
612        accent_active: gpui::Hsla,
613    ) -> gpui::Div {
614        // Slots ornament del theme para los menu items de abajo.
615        let theme = Theme::global(cx);
616        let menu_active_bg = theme.bg_row_active;
617        let menu_hover_bg = theme.bg_row_hover;
618        let mut sidebar = div()
619            .w(px(240.))
620            .h_full()
621            .bg(panel)
622            .border_r_1()
623            .border_color(border)
624            .flex()
625            .flex_col();
626
627        // Sidebar header: título + theme switcher en una row.
628        sidebar = sidebar.child(
629            div()
630                .flex()
631                .flex_row()
632                .items_center()
633                .px(px(12.))
634                .py(px(10.))
635                .text_color(text)
636                .text_size(px(13.))
637                .child(div().flex_grow().child("Nakui"))
638                .child(theme_switcher(cx)),
639        );
640
641        if self.modules.is_empty() {
642            return sidebar.child(
643                div()
644                    .px(px(12.))
645                    .py(px(8.))
646                    .text_color(text_dim)
647                    .text_size(px(11.))
648                    .child("(no hay módulos cargados)"),
649            );
650        }
651
652        // Snapshot del active para evitar borrow del self adentro de la closure.
653        let active_snapshot = self.active.clone();
654
655        for (mod_idx, m) in self.modules.iter().enumerate() {
656            sidebar = sidebar.child(
657                div()
658                    .px(px(12.))
659                    .py(px(8.))
660                    .border_t_1()
661                    .border_color(border)
662                    .text_color(text)
663                    .text_size(px(12.))
664                    .child(m.label.clone()),
665            );
666
667            for item in &m.menu {
668                let is_active = active_snapshot
669                    .as_ref()
670                    .map(|(i, v)| *i == mod_idx && v == &item.view)
671                    .unwrap_or(false);
672                let label = item
673                    .icon
674                    .as_deref()
675                    .map(|ic| format!("{ic}  {}", item.label))
676                    .unwrap_or_else(|| item.label.clone());
677
678                let view_key = item.view.clone();
679                sidebar = sidebar.child(
680                    div()
681                        .id(SharedString::from(format!(
682                            "menu-{}-{}",
683                            mod_idx, item.view
684                        )))
685                        .px(px(20.))
686                        .py(px(6.))
687                        .text_size(px(12.))
688                        .text_color(if is_active { accent_active } else { text_dim })
689                        .when(is_active, move |d| d.bg(menu_active_bg).text_color(text))
690                        .hover(move |d| d.bg(menu_hover_bg))
691                        .child(label)
692                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
693                            this.select_view(mod_idx, view_key.clone(), cx);
694                        })),
695                );
696            }
697        }
698        sidebar
699    }
700
701    fn render_main(
702        &mut self,
703        cx: &mut Context<Self>,
704        panel: gpui::Background,
705        border: gpui::Hsla,
706        text: gpui::Hsla,
707        text_dim: gpui::Hsla,
708        accent: gpui::Hsla,
709    ) -> gpui::Div {
710        let main = div()
711            .flex_grow()
712            .h_full()
713            .bg(panel)
714            .flex()
715            .flex_col()
716            .p(px(16.));
717
718        let (mod_idx, view_key) = match &self.active {
719            Some(a) => (a.0, a.1.clone()),
720            None => {
721                return main.child(
722                    div()
723                        .text_color(text_dim)
724                        .child("Seleccioná un menú a la izquierda."),
725                );
726            }
727        };
728
729        let view = match self
730            .modules
731            .get(mod_idx)
732            .and_then(|m| m.views.get(&view_key))
733        {
734            Some(v) => v.clone(),
735            None => {
736                return main.child(
737                    div()
738                        .text_color(text_dim)
739                        .child(format!("Vista no encontrada: {view_key}")),
740                );
741            }
742        };
743
744        match view {
745            View::List(lv) => {
746                self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent)
747            }
748            View::Form(fv) => {
749                self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent)
750            }
751        }
752    }
753
754    #[allow(clippy::too_many_arguments)]
755    fn render_list(
756        &self,
757        cx: &mut Context<Self>,
758        mut main: gpui::Div,
759        lv: &ListView,
760        mod_idx: usize,
761        border: gpui::Hsla,
762        text: gpui::Hsla,
763        text_dim: gpui::Hsla,
764        accent: gpui::Hsla,
765    ) -> gpui::Div {
766        // Ornament secundarios del theme para hovers, row separators,
767        // botones inline (edit ✎, delete ✕).
768        let theme = Theme::global(cx);
769        let row_separator = theme.bg_row_active;
770        let action_bg = theme.bg_button();
771        let action_hover = theme.bg_button_hover();
772        let destructive_fg = theme.accent_destructive();
773        let destructive_hover = theme.bg_destructive_hover();
774        let mut header = div()
775            .flex()
776            .flex_row()
777            .items_center()
778            .gap(px(12.))
779            .mb(px(12.))
780            .child(
781                div()
782                    .text_color(text)
783                    .text_size(px(18.))
784                    .child(lv.title.clone()),
785            );
786        for (idx, action) in lv.actions.iter().enumerate() {
787            let label = match action {
788                Action::OpenView { label, view } => {
789                    label.clone().unwrap_or_else(|| format!("→ {view}"))
790                }
791                Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
792                Action::Morphism { name, .. } => format!("⚡ {name}"),
793            };
794            let action_clone = action.clone();
795            header = header.child(
796                div()
797                    .id(SharedString::from(format!("list-action-{mod_idx}-{idx}")))
798                    .px(px(10.))
799                    .py(px(4.))
800                    .bg(action_bg)
801                    .text_color(accent)
802                    .text_size(px(11.))
803                    .rounded(px(3.))
804                    .hover(move |d| d.bg(action_hover))
805                    .child(label)
806                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
807                        this.apply_action(action_clone.clone(), cx);
808                    })),
809            );
810        }
811        main = main.child(header);
812
813        let rows = self.list_rows(&lv.entity);
814        let total = rows.len();
815
816        let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
817        let mut col_header = div()
818            .flex()
819            .flex_row()
820            .py(px(6.))
821            .border_b_1()
822            .border_color(border)
823            .text_color(text_dim)
824            .text_size(px(11.));
825        for c in &lv.columns {
826            let frac = c.weight / total_weight;
827            col_header = col_header.child(
828                div()
829                    .flex_grow()
830                    .flex_basis(px(100. * frac))
831                    .child(c.label.clone()),
832            );
833        }
834        col_header = col_header
835            .child(div().w(px(80.)).text_color(text_dim).child("id"))
836            .child(div().w(px(70.)).text_color(text_dim).child("acciones"));
837        main = main.child(col_header);
838
839        let entity_name = lv.entity.clone();
840        for (id, value) in &rows {
841            let id_copy = *id;
842            let entity_for_edit = entity_name.clone();
843            let entity_for_delete = entity_name.clone();
844            let mut row = div()
845                .flex()
846                .flex_row()
847                .py(px(6.))
848                .border_b_1()
849                .border_color(row_separator)
850                .text_color(text)
851                .text_size(px(12.));
852            for c in &lv.columns {
853                let frac = c.weight / total_weight;
854                let v = lookup_field(value, &c.field);
855                row = row.child(
856                    div()
857                        .flex_grow()
858                        .flex_basis(px(100. * frac))
859                        .child(self.render_cell(c, v)),
860                );
861            }
862            row = row.child(
863                div()
864                    .w(px(80.))
865                    .text_color(text_dim)
866                    .text_size(px(11.))
867                    .child(short_uuid(id)),
868            );
869            // Acciones: ✎ edit + ✕ delete por fila.
870            row = row.child(
871                div()
872                    .w(px(70.))
873                    .flex()
874                    .flex_row()
875                    .gap(px(4.))
876                    .child(
877                        div()
878                            .id(SharedString::from(format!("row-edit-{mod_idx}-{id_copy}")))
879                            .px(px(6.))
880                            .text_color(accent)
881                            .text_size(px(13.))
882                            .hover(move |d| d.bg(action_hover))
883                            .child("✎")
884                            .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
885                                this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx);
886                            })),
887                    )
888                    .child(
889                        div()
890                            .id(SharedString::from(format!("row-del-{mod_idx}-{id_copy}")))
891                            .px(px(6.))
892                            .text_color(destructive_fg)
893                            .text_size(px(13.))
894                            .hover(move |d| d.bg(destructive_hover))
895                            .child("✕")
896                            .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
897                                // Marca para borrar en lugar de borrar
898                                // directo: el modal de confirmación se
899                                // renderea arriba en `render` y maneja
900                                // confirm/cancel.
901                                this.pending_delete = Some((entity_for_delete.clone(), id_copy));
902                                this.toast = None;
903                                cx.notify();
904                            })),
905                    ),
906            );
907            main = main.child(row);
908        }
909
910        if rows.is_empty() {
911            main = main.child(
912                div()
913                    .py(px(12.))
914                    .text_color(text_dim)
915                    .text_size(px(12.))
916                    .child(format!("(sin {})", lv.entity)),
917            );
918        } else {
919            main = main.child(
920                div()
921                    .mt(px(8.))
922                    .text_color(text_dim)
923                    .text_size(px(11.))
924                    .child(format!("{total} fila(s)")),
925            );
926        }
927
928        main
929    }
930
931    /// Renderea el selector clickable de records existentes para un
932    /// FieldSpec con kind=EntityRef. Lista compacta debajo del input;
933    /// click en una opción setea el TextInput del field con el UUID
934    /// seleccionado. El item del UUID actualmente seleccionado (si
935    /// hay) se resalta con accent color.
936    /// Render del valor de una celda de lista. Una columna con
937    /// `ref_entity` resuelve su UUID al label del record referido; el
938    /// resto aplica el `ValueFormat` declarado en la columna.
939    fn render_cell(&self, c: &Column, v: Option<&Value>) -> String {
940        if let Some(ref_entity) = &c.ref_entity {
941            return match v {
942                Some(Value::String(s)) => match Uuid::parse_str(s) {
943                    Ok(uuid) => self
944                        .backend
945                        .load_record(ref_entity, uuid)
946                        .map(|rec| human_label_for_record(&rec, &uuid))
947                        .unwrap_or_else(|| format!("(borrado · {})", short_uuid(&uuid))),
948                    Err(_) => render_value(v),
949                },
950                _ => render_value(v),
951            };
952        }
953        format_value(v, &c.format)
954    }
955
956    /// Label legible del record referenciado por un campo EntityRef.
957    /// `(sin seleccionar)` si el campo está vacío.
958    fn ref_label(&self, target: &str, current: &str) -> String {
959        let current = current.trim();
960        if current.is_empty() {
961            return "(sin seleccionar)".to_string();
962        }
963        match Uuid::parse_str(current) {
964            Ok(uuid) => self
965                .backend
966                .load_record(target, uuid)
967                .map(|rec| human_label_for_record(&rec, &uuid))
968                .unwrap_or_else(|| format!("(borrado · {})", short_uuid(&uuid))),
969            Err(_) => current.to_string(),
970        }
971    }
972
973    /// Chips clickables para un campo [`FieldKind::Select`]. El chip de
974    /// la opción elegida se resalta con accent. Click setea el
975    /// `TextInput` del field (de donde lee el submit), igual que el
976    /// selector de EntityRef.
977    fn render_select_chips(
978        &self,
979        cx: &mut Context<Self>,
980        field_name: String,
981        options: &[SelectOption],
982        text_dim: gpui::Hsla,
983        accent: gpui::Hsla,
984    ) -> gpui::Div {
985        let theme = Theme::global(cx);
986        let row_active = theme.bg_row_active;
987        let row_hover = theme.bg_row_hover;
988        let border = theme.border;
989        let current = self
990            .form_inputs
991            .get(&field_name)
992            .map(|inp| inp.read(&*cx).text().to_string())
993            .unwrap_or_default();
994
995        let mut row = div().mt(px(2.)).flex().flex_row().flex_wrap().gap(px(4.));
996
997        for opt in options {
998            let value = opt.value.clone();
999            let is_selected = current == value;
1000            let field_for_click = field_name.clone();
1001            let value_for_click = value.clone();
1002            row = row.child(
1003                div()
1004                    .id(SharedString::from(format!("select-{field_name}-{value}")))
1005                    .px(px(8.))
1006                    .py(px(3.))
1007                    .rounded(px(10.))
1008                    .border_1()
1009                    .border_color(if is_selected { accent } else { border })
1010                    .text_size(px(11.))
1011                    .text_color(if is_selected { accent } else { text_dim })
1012                    .when(is_selected, move |d| d.bg(row_active))
1013                    .hover(move |d| d.bg(row_hover))
1014                    .child(opt.display().to_string())
1015                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1016                        if let Some(input) = this.form_inputs.get(&field_for_click) {
1017                            input.update(cx, |inp, cx| inp.set_text(value_for_click.clone(), cx));
1018                        }
1019                        cx.notify();
1020                    })),
1021            );
1022        }
1023        row
1024    }
1025
1026    #[allow(clippy::too_many_arguments)]
1027    fn render_entity_ref_selector(
1028        &self,
1029        cx: &mut Context<Self>,
1030        field_name: String,
1031        target_entity: String,
1032        text: gpui::Hsla,
1033        text_dim: gpui::Hsla,
1034        accent: gpui::Hsla,
1035    ) -> gpui::Div {
1036        let _ = text;
1037        // Slots ornament para hover/selected del selector + border.
1038        let theme = Theme::global(cx);
1039        let row_active = theme.bg_row_active;
1040        let row_hover = theme.bg_row_hover;
1041        let rows = self.list_rows(&target_entity);
1042        let current = self
1043            .form_inputs
1044            .get(&field_name)
1045            .map(|inp| inp.read(&*cx).text().to_string())
1046            .unwrap_or_default();
1047
1048        let mut container = div()
1049            .mt(px(4.))
1050            .pl(px(8.))
1051            .border_l_2()
1052            .border_color(theme.border)
1053            .flex()
1054            .flex_col()
1055            .gap(px(2.));
1056
1057        if rows.is_empty() {
1058            return container.child(
1059                div()
1060                    .px(px(6.))
1061                    .py(px(4.))
1062                    .text_color(text_dim)
1063                    .text_size(px(10.))
1064                    .child(format!(
1065                        "(sin {target_entity}: creá uno antes para referenciar)"
1066                    )),
1067            );
1068        }
1069
1070        container = container.child(
1071            div()
1072                .text_color(text_dim)
1073                .text_size(px(10.))
1074                .child(format!("Seleccioná un {target_entity}:")),
1075        );
1076
1077        for (id, value) in &rows {
1078            let label = human_label_for_record(value, id);
1079            let id_str = id.to_string();
1080            let is_selected = current == id_str;
1081            let field_for_click = field_name.clone();
1082            let id_for_click = id_str.clone();
1083            container = container.child(
1084                div()
1085                    .id(SharedString::from(format!(
1086                        "entity-ref-{field_name}-{id_str}"
1087                    )))
1088                    .px(px(6.))
1089                    .py(px(2.))
1090                    .text_size(px(11.))
1091                    .text_color(if is_selected { accent } else { text_dim })
1092                    .when(is_selected, move |d| d.bg(row_active))
1093                    .hover(move |d| d.bg(row_hover))
1094                    .child(label)
1095                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1096                        if let Some(input) = this.form_inputs.get(&field_for_click) {
1097                            input.update(cx, |inp, cx| inp.set_text(id_for_click.clone(), cx));
1098                        }
1099                        cx.notify();
1100                    })),
1101            );
1102        }
1103        container
1104    }
1105
1106    #[allow(clippy::too_many_arguments)]
1107    fn render_form(
1108        &self,
1109        cx: &mut Context<Self>,
1110        mut main: gpui::Div,
1111        fv: &FormView,
1112        mod_idx: usize,
1113        _border: gpui::Hsla,
1114        text: gpui::Hsla,
1115        text_dim: gpui::Hsla,
1116        accent: gpui::Hsla,
1117    ) -> gpui::Div {
1118        // Slots ornament para el botón submit + bg de fallback inputs.
1119        let theme = Theme::global(cx);
1120        let submit_bg = theme.bg_button();
1121        let submit_hover = theme.bg_button_hover();
1122        let input_bg = theme.bg_input();
1123        // En modo edit, el título refleja eso para que el user no
1124        // se confunda creyendo que hace alta nueva.
1125        let title = match self.editing.as_ref() {
1126            Some((e, id)) if e == &fv.entity => {
1127                format!("Editar {} {}", fv.entity, short_uuid(id))
1128            }
1129            _ => fv.title.clone(),
1130        };
1131        main = main.child(
1132            div()
1133                .text_color(text)
1134                .text_size(px(18.))
1135                .mb(px(12.))
1136                .child(title),
1137        );
1138        for f in &fv.fields {
1139            let label = if f.required {
1140                format!("{} *", f.label)
1141            } else {
1142                f.label.clone()
1143            };
1144
1145            let mut field_box = div().flex().flex_col().mb(px(10.)).child(
1146                div()
1147                    .text_color(text_dim)
1148                    .text_size(px(11.))
1149                    .mb(px(2.))
1150                    .child(label),
1151            );
1152
1153            // Render según el kind. AutoId y Select NO montan el
1154            // TextInput editable —aunque sí existe en `form_inputs`, de
1155            // donde lee el submit—; presentan su propio control.
1156            match f.kind {
1157                FieldKind::AutoId => {
1158                    // Read-only: el UUID autogenerado, sólo informativo.
1159                    let val = self
1160                        .form_inputs
1161                        .get(&f.name)
1162                        .map(|i| i.read(&*cx).text().to_string())
1163                        .unwrap_or_default();
1164                    field_box = field_box.child(
1165                        div()
1166                            .px(px(8.))
1167                            .py(px(6.))
1168                            .bg(input_bg)
1169                            .text_color(text_dim)
1170                            .text_size(px(11.))
1171                            .child(format!("{val}  ·  automático")),
1172                    );
1173                }
1174                FieldKind::Select => {
1175                    field_box = field_box.child(self.render_select_chips(
1176                        cx,
1177                        f.name.clone(),
1178                        &f.options,
1179                        text_dim,
1180                        accent,
1181                    ));
1182                }
1183                FieldKind::EntityRef => {
1184                    // Display read-only del record elegido (label, no
1185                    // el UUID crudo) + selector clickable debajo. El
1186                    // TextInput vive en `form_inputs` pero no se monta.
1187                    if let Some(target) = &f.ref_entity {
1188                        let current = self
1189                            .form_inputs
1190                            .get(&f.name)
1191                            .map(|i| i.read(&*cx).text().to_string())
1192                            .unwrap_or_default();
1193                        let is_empty = current.trim().is_empty();
1194                        field_box = field_box.child(
1195                            div()
1196                                .px(px(8.))
1197                                .py(px(6.))
1198                                .bg(input_bg)
1199                                .text_color(if is_empty { text_dim } else { text })
1200                                .text_size(px(11.))
1201                                .child(self.ref_label(target, &current)),
1202                        );
1203                        field_box = field_box.child(self.render_entity_ref_selector(
1204                            cx,
1205                            f.name.clone(),
1206                            target.clone(),
1207                            text,
1208                            text_dim,
1209                            accent,
1210                        ));
1211                    }
1212                    // Sin ref_entity es imposible: Module::validate lo
1213                    // rechaza al cargar el módulo.
1214                }
1215                _ => {
1216                    // Mount del TextInput vivo (creado en select_view).
1217                    if let Some(input) = self.form_inputs.get(&f.name) {
1218                        field_box = field_box.child(input.clone());
1219                    } else {
1220                        // No debería pasar — select_view crea inputs por
1221                        // cada field. Fallback estático por seguridad.
1222                        field_box = field_box.child(
1223                            div()
1224                                .px(px(8.))
1225                                .py(px(6.))
1226                                .bg(input_bg)
1227                                .text_color(text_dim)
1228                                .child("(input no inicializado)"),
1229                        );
1230                    }
1231                }
1232            }
1233
1234            if let Some(help) = &f.help {
1235                field_box = field_box.child(
1236                    div()
1237                        .mt(px(2.))
1238                        .text_color(text_dim)
1239                        .text_size(px(10.))
1240                        .child(help.clone()),
1241                );
1242            }
1243            main = main.child(field_box);
1244        }
1245
1246        let editing_this = matches!(
1247            self.editing.as_ref(),
1248            Some((e, _)) if e == &fv.entity
1249        );
1250        let submit_label = match &fv.on_submit {
1251            Action::SeedEntity { entity, .. } => {
1252                if editing_this {
1253                    format!("Guardar cambios en {entity}")
1254                } else {
1255                    format!("Crear {entity}")
1256                }
1257            }
1258            Action::Morphism { name, .. } => format!("Ejecutar {name}"),
1259            Action::OpenView { label, view } => {
1260                label.clone().unwrap_or_else(|| format!("Ir a {view}"))
1261            }
1262        };
1263        let submit_action = fv.on_submit.clone();
1264        main = main.child(
1265            div().mt(px(12.)).child(
1266                div()
1267                    .id(SharedString::from(format!("form-submit-{mod_idx}")))
1268                    .px(px(12.))
1269                    .py(px(6.))
1270                    .bg(submit_bg)
1271                    .text_color(accent)
1272                    .text_size(px(12.))
1273                    .rounded(px(3.))
1274                    .hover(move |d| d.bg(submit_hover))
1275                    .child(submit_label)
1276                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1277                        this.apply_action(submit_action.clone(), cx);
1278                    })),
1279            ),
1280        );
1281
1282        main = main.child(
1283            div()
1284                .mt(px(20.))
1285                .text_color(text_dim)
1286                .text_size(px(10.))
1287                .child(
1288                    "Tip: click en el campo para enfocar; Enter no envía (todavía), \
1289                     usá el botón. Backspace borra el último carácter.",
1290                ),
1291        );
1292        main
1293    }
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298    //! Tests del widget. Funciones puras (sin GPUI cx) sólo. Los
1299    //! tests de backend impl viven en el binario que provee el
1300    //! backend (ej: nakui-ui). Los helpers movidos a
1301    //! nahual-meta-runtime se testean allí.
1302    use super::*;
1303    use serde_json::json;
1304
1305    #[test]
1306    fn lookup_field_simple_and_nested() {
1307        let v = json!({
1308            "name": "Acme",
1309            "address": { "city": "Bogotá", "country": "CO" }
1310        });
1311        assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
1312        assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá"));
1313        assert!(lookup_field(&v, "missing").is_none());
1314        assert!(lookup_field(&v, "address.zipcode").is_none());
1315    }
1316
1317    #[test]
1318    fn append_compact_msg_handles_both_branches() {
1319        // Sin compact: base solo, sin separador.
1320        let s = append_compact_msg("creado X".into(), None);
1321        assert_eq!(s.as_ref(), "creado X");
1322        // Con compact: concatena con "; ".
1323        let s = append_compact_msg(
1324            "creado X".into(),
1325            Some("auto-compact: snapshot @ seq 49".into()),
1326        );
1327        assert_eq!(s.as_ref(), "creado X; auto-compact: snapshot @ seq 49");
1328    }
1329
1330    #[test]
1331    fn format_seed_toast_distinguishes_create_update_no_change() {
1332        let id = Uuid::new_v4();
1333        let outcome_create = WriteOutcome {
1334            id: Some(id),
1335            changed: 1,
1336            post_status: None,
1337        };
1338        let toast = format_seed_toast("Customer", false, &outcome_create);
1339        assert!(toast.starts_with("creado Customer"));
1340
1341        let outcome_update = WriteOutcome {
1342            id: Some(id),
1343            changed: 3,
1344            post_status: None,
1345        };
1346        let toast = format_seed_toast("Customer", true, &outcome_update);
1347        assert!(toast.starts_with("actualizado Customer"));
1348        assert!(toast.contains("(3 campo(s))"));
1349
1350        let outcome_no_change = WriteOutcome::no_change(id);
1351        let toast = format_seed_toast("Customer", true, &outcome_no_change);
1352        assert!(toast.contains("sin cambios"));
1353    }
1354}