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