⎇ 3e4278d76625191a37bd37b57c333fd5431ac508
crates/modules/ui_engine/widgets/meta-form/src/lib.rs
History
Who touched what
raw
feat(yahweh): MockBackend público + tests E2E del widget con TestAppContext
1//! `yahweh-widget-meta-form` — widget GPUI de metainterfaz.
2//!
3//! Renderea cualquier conjunto de [`yahweh_meta_schema::Module`] sobre
4//! cualquier impl de [`yahweh_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 serde_json::Value;
30use uuid::Uuid;
31use yahweh_meta_runtime::{
32 compute_clear_fields, compute_field_delta, human_label_for_record, parse_field_value,
33 render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text,
34 MetaBackend, WriteOutcome,
35};
36use yahweh_meta_schema::{Action, FieldKind, FieldSpec, FormView, ListView, Module, View};
37use yahweh_widget_text_input::TextInput;
38
39/// Estado del runtime de UI. Toda la persistencia/ejecución está
40/// detrás del trait [`MetaBackend`]; este struct sólo conoce GPUI
41/// state y el schema de los módulos.
42///
43/// Genérico sobre el backend `B`. Un binario decide qué backend
44/// proveer (Nakui via `NakuiBackend`, mock para tests, etc.).
45pub struct MetaApp<B: MetaBackend> {
46 /// Módulos cargados, ordenados por id.
47 modules: Vec<Module>,
48 /// Backend que ejecuta seed/update/delete/morphism.
49 backend: B,
50 /// (módulo idx, vista key) actualmente activos.
51 active: Option<(usize, String)>,
52 /// Inputs vivos para el form actual: nombre del campo → TextInput.
53 /// Se reemplaza al cambiar de vista (drop de los anteriores).
54 form_inputs: BTreeMap<String, Entity<TextInput>>,
55 /// Si está set, el próximo render del Form pre-llena los inputs
56 /// con los valores del record indicado, y `commit_seed` emite
57 /// un `update` (no un seed nuevo). Limpia al cambiar de view o
58 /// tras submit exitoso.
59 editing: Option<(String, Uuid)>,
60 /// Si está set, el banner modal de confirmación de delete está
61 /// activo: `(entity, id)` del record que el usuario marcó para
62 /// borrar. Permanece hasta que el usuario click [Confirmar]
63 /// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia).
64 /// Navegación a otra view también cancela.
65 pending_delete: Option<(String, Uuid)>,
66 /// Mensaje toast al pie (success de submit, error de carga, etc.).
67 toast: Option<SharedString>,
68 /// Si la carga de módulos falló al inicio.
69 load_error: Option<SharedString>,
70}
71
72impl<B: MetaBackend> MetaApp<B> {
73 /// Constructor del widget. El caller pre-construye sus módulos +
74 /// backend + cualquier mensaje inicial que quiera mostrar al
75 /// usuario al abrir (típicamente el toast del replay y el error
76 /// banner de cosas que fallaron de bootstrap).
77 ///
78 /// La active view default es la primera entry del menú del
79 /// primer módulo (orden lexicográfico por id).
80 pub fn new(
81 modules: Vec<Module>,
82 backend: B,
83 initial_toast: Option<String>,
84 initial_error: Option<String>,
85 _cx: &mut Context<Self>,
86 ) -> Self {
87 let active = modules
88 .first()
89 .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
90 Self {
91 modules,
92 backend,
93 active,
94 form_inputs: BTreeMap::new(),
95 editing: None,
96 pending_delete: None,
97 toast: initial_toast.map(SharedString::from),
98 load_error: initial_error.map(SharedString::from),
99 }
100 }
101
102 /// Cambia la vista activa. Si la nueva vista es un Form, crea
103 /// `TextInput` entities para cada field. Pre-llena con valores
104 /// del record si hay `editing` para esa entity; si no, usa el
105 /// `default` del schema.
106 /// Drop de los inputs anteriores ocurre al sobreescribir el map.
107 fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context<Self>) {
108 self.active = Some((mod_idx, view_key.clone()));
109 self.toast = None;
110 // Navegar a otra view cancela cualquier delete pendiente:
111 // el record marcado puede no estar visible en la nueva view.
112 self.pending_delete = None;
113 self.form_inputs = BTreeMap::new();
114 if let Some(module) = self.modules.get(mod_idx) {
115 if let Some(View::Form(form)) = module.views.get(&view_key) {
116 // Snapshot del record si estamos editando esta entity.
117 let editing_record: Option<Value> = self.editing.as_ref().and_then(|(e, id)| {
118 if e == &form.entity {
119 self.backend.load_record(e, *id)
120 } else {
121 None
122 }
123 });
124 for f in &form.fields {
125 let initial = if let Some(rec) = &editing_record {
126 rec.get(&f.name)
127 .map(value_to_input_text)
128 .unwrap_or_else(|| f.default.clone().unwrap_or_default())
129 } else {
130 f.default.clone().unwrap_or_default()
131 };
132 let input = cx.new(|cx| TextInput::new(initial, cx));
133 self.form_inputs.insert(f.name.clone(), input);
134 }
135 } else {
136 // Cambiar a una view que no es Form invalida el editing
137 // pendiente.
138 self.editing = None;
139 }
140 }
141 cx.notify();
142 }
143
144 /// Inicia un edit del record: setea `editing` y abre la primera
145 /// view de tipo Form del módulo (convención: la del schema).
146 fn open_edit(
147 &mut self,
148 mod_idx: usize,
149 entity: String,
150 id: Uuid,
151 cx: &mut Context<Self>,
152 ) {
153 self.editing = Some((entity.clone(), id));
154 let form_view_key = self.modules.get(mod_idx).and_then(|m| {
155 m.views
156 .iter()
157 .find_map(|(key, v)| match v {
158 View::Form(form) if form.entity == entity => Some(key.clone()),
159 _ => None,
160 })
161 });
162 match form_view_key {
163 Some(key) => self.select_view(mod_idx, key, cx),
164 None => {
165 self.toast = Some(SharedString::from(format!(
166 "no hay form view para entity '{entity}' en este módulo"
167 )));
168 self.editing = None;
169 cx.notify();
170 }
171 }
172 }
173
174 /// Borra un record. Emite Morphism con un FieldOp::Delete + lo
175 /// aplica al store via `apply` (no via remove directo, mantiene
176 /// el modelo de "todo cambio post-seed pasa por ops").
177 /// Borra un record vía `MetaBackend::delete`. Devuelve el outcome
178 /// del backend (incluye eventual `post_status` del compact tick).
179 fn commit_delete(
180 &mut self,
181 entity: &str,
182 id: Uuid,
183 ) -> 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, ¶ms, cx) {
231 Ok(outcome) => {
232 let base = format!(
233 "morphism '{name}' OK ({} op(s) aplicadas)",
234 outcome.changed
235 );
236 self.toast = Some(append_compact_msg(base, outcome.post_status));
237 if let Some(v) = next_view {
238 self.select_view(mod_idx, v, cx);
239 }
240 }
241 Err(e) => {
242 self.toast = Some(SharedString::from(format!(
243 "morphism '{name}' falló: {e}"
244 )));
245 }
246 }
247 cx.notify();
248 }
249 }
250 }
251
252 /// Despacha un morphism via el backend. Resuelve inputs (UUIDs)
253 /// + params (Value object) leyendo los TextInput del form.
254 fn commit_morphism(
255 &mut self,
256 mod_idx: usize,
257 morphism: &str,
258 inputs_map: &BTreeMap<String, String>,
259 params_fields: &[String],
260 cx: &mut Context<Self>,
261 ) -> Result<WriteOutcome, String> {
262 let module = self
263 .modules
264 .get(mod_idx)
265 .ok_or_else(|| "módulo inválido".to_string())?;
266 let module_id = module.id.clone();
267
268 // Resolver inputs: por cada (role, field_name), parsear el
269 // value del input como Uuid.
270 let mut inputs: BTreeMap<String, Uuid> = BTreeMap::new();
271 for (role, field_name) in inputs_map {
272 let raw = self
273 .form_inputs
274 .get(field_name)
275 .map(|inp| inp.read(&*cx).text().to_string())
276 .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
277 let id = Uuid::parse_str(raw.trim()).map_err(|_| {
278 format!(
279 "input '{role}' (field '{field_name}'): '{raw}' no es UUID válido"
280 )
281 })?;
282 inputs.insert(role.clone(), id);
283 }
284
285 // Resolver params: si la lista está vacía, todos los fields
286 // del form que no estén en `inputs_map` van a params.
287 let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect();
288 let mut params_obj = serde_json::Map::new();
289 let field_iter: Vec<String> = if params_fields.is_empty() {
290 self.form_inputs
291 .keys()
292 .filter(|k| !input_field_set.contains(*k))
293 .cloned()
294 .collect()
295 } else {
296 params_fields.to_vec()
297 };
298
299 // FieldSpec del Form view activo para parseo estricto por kind.
300 let active_form_fields: Option<Vec<FieldSpec>> =
301 self.active.as_ref().and_then(|(_, vk)| {
302 module.views.get(vk).and_then(|v| match v {
303 View::Form(f) => Some(f.fields.clone()),
304 _ => None,
305 })
306 });
307
308 for field_name in field_iter {
309 let raw = self
310 .form_inputs
311 .get(&field_name)
312 .map(|inp| inp.read(&*cx).text().to_string())
313 .unwrap_or_default();
314 let spec = active_form_fields
315 .as_ref()
316 .and_then(|fs| fs.iter().find(|f| f.name == field_name));
317 let value = resolve_param_value(&field_name, &raw, spec)?;
318 params_obj.insert(field_name, value);
319 }
320
321 self.backend
322 .morphism(&module_id, morphism, inputs, Value::Object(params_obj))
323 }
324
325 /// Construye un payload desde los TextInput vivos y delega al
326 /// backend (`seed` para alta nueva, `update` con set+clear para
327 /// edit). Devuelve el `WriteOutcome` del backend (incluye
328 /// `changed` y `post_status` para el toast).
329 fn commit_seed(
330 &mut self,
331 mod_idx: usize,
332 entity: &str,
333 cx: &mut Context<Self>,
334 ) -> Result<WriteOutcome, String> {
335 let module = &self.modules[mod_idx];
336 let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
337 Some((_, view_key)) => match module.views.get(view_key) {
338 Some(View::Form(f)) => f.fields.clone(),
339 _ => return Err("la vista activa no es un Form".into()),
340 },
341 None => return Err("ninguna vista activa".into()),
342 };
343 let mut obj = serde_json::Map::new();
344 // Fields que el form deja vacíos y son optional: candidatos
345 // a Clear en el path de EDIT.
346 let mut to_clear: Vec<String> = Vec::new();
347 // EntityRef refs a validar tras parse loop (en una toma del
348 // backend en lugar de N).
349 let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new();
350 for f in &spec_fields {
351 let raw = self
352 .form_inputs
353 .get(&f.name)
354 .map(|input| input.read(cx).text().to_string())
355 .unwrap_or_default();
356 if f.required && raw.trim().is_empty() {
357 return Err(format!("campo '{}' es obligatorio", f.label));
358 }
359 if raw.is_empty() && !f.required {
360 to_clear.push(f.name.clone());
361 continue;
362 }
363 let value = parse_field_value(f.kind, &raw)
364 .map_err(|e| format!("campo '{}': {e}", f.label))?;
365 if f.kind == FieldKind::EntityRef {
366 if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
367 let id = Uuid::parse_str(uuid_str)
368 .expect("parse_field_value validated UUID");
369 entity_refs.push((f.label.clone(), target.clone(), id));
370 }
371 }
372 obj.insert(f.name.clone(), value);
373 }
374 // Validar EntityRefs contra el backend actual. Cierre wrappea
375 // backend.load_record para mantener la firma de
376 // validate_entity_refs (que es store-agnóstica).
377 if !entity_refs.is_empty() {
378 let backend = &self.backend;
379 validate_entity_refs(|e, id| backend.load_record(e, id), &entity_refs)?;
380 }
381
382 // Ramificación: edit (hay editing para esta entity) vs alta.
383 let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
384
385 if let Some((_, id)) = editing_match {
386 // EDIT: cargar current, computar delta, llamar
387 // backend.update con set+clear pre-computados.
388 let current = self.backend.load_record(entity, id).unwrap_or(Value::Null);
389 let set_delta = compute_field_delta(¤t, &obj);
390 let clear_fields = compute_clear_fields(¤t, &to_clear);
391 self.backend.update(entity, id, set_delta, clear_fields)
392 } else {
393 // SEED: alta nueva — el backend genera el Uuid.
394 self.backend.seed(entity, obj)
395 }
396 }
397
398 /// Snapshot ordenado de records de una entity (proxy al backend).
399 fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
400 self.backend.list_records(entity)
401 }
402}
403
404/// Formatea el toast para la rama Action::SeedEntity según el
405/// `WriteOutcome` del backend. `was_editing` distingue "creado"
406/// vs "actualizado" — el WriteOutcome solo no alcanza porque
407/// `seed` y `update` ambos devuelven `id = Some(...)`.
408fn format_seed_toast(entity: &str, was_editing: bool, outcome: &WriteOutcome) -> String {
409 let id_short = outcome
410 .id
411 .map(|id| short_uuid(&id))
412 .unwrap_or_default();
413 match (was_editing, outcome.changed) {
414 (false, _) => format!("creado {entity} {id_short}"),
415 (true, 0) => format!("{entity} {id_short} sin cambios — no log entry"),
416 (true, n) => format!("actualizado {entity} {id_short} ({n} campo(s))"),
417 }
418}
419
420/// Concatena el msg opcional del compact tick al toast del op
421/// original con `"; "` separator.
422fn append_compact_msg(base: String, compact_msg: Option<String>) -> SharedString {
423 match compact_msg {
424 Some(m) => SharedString::from(format!("{base}; {m}")),
425 None => SharedString::from(base),
426 }
427}
428
429/// Walker dentro de un `Value` por path con `.` como separador.
430/// Local porque sólo lo usa la lista renderer y no tiene tests
431/// dedicados afuera. Si crece su uso se puede mover a meta-runtime.
432fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
433 let mut cur = v;
434 for seg in path.split('.') {
435 cur = cur.get(seg)?;
436 }
437 Some(cur)
438}
439
440impl<B: MetaBackend> Render for MetaApp<B> {
441 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
442 let bg = gpui::rgb(0x14171c);
443 let panel = gpui::rgb(0x1d2128);
444 let border = gpui::rgb(0x2a2f38);
445 let text = gpui::rgb(0xe6e8ec);
446 let text_dim = gpui::rgb(0x9ba1ad);
447 let accent = gpui::rgb(0x88c0d0);
448 let accent_active = gpui::rgb(0xa3be8c);
449
450 let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
451 let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
452 let confirm_banner = self.render_confirm_delete_banner(cx);
453 let toast_div = self.toast.as_ref().map(|t| {
454 div()
455 .px(px(12.))
456 .py(px(6.))
457 .bg(gpui::rgb(0x2d3a2a))
458 .text_color(gpui::rgb(0xc0e0a0))
459 .text_size(px(11.))
460 .child(t.clone())
461 });
462 let error_banner = self.load_error.as_ref().map(|e| {
463 div()
464 .px(px(12.))
465 .py(px(6.))
466 .bg(gpui::rgb(0x4a2020))
467 .text_color(gpui::rgb(0xffd0d0))
468 .text_size(px(11.))
469 .child(e.clone())
470 });
471
472 div()
473 .flex()
474 .flex_col()
475 .size_full()
476 .bg(bg)
477 // Capture phase: el Esc llega al root ANTES que cualquier
478 // TextInput descendiente. Si hay un delete pendiente, lo
479 // cancelamos. Sin pending no hacemos nada (el evento sigue
480 // su flujo normal y el TextInput recibe el Esc bubble).
481 .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _w, cx| {
482 if event.keystroke.key != "escape" {
483 return;
484 }
485 if let Some((entity, _id)) = this.pending_delete.take() {
486 this.toast = Some(SharedString::from(format!(
487 "delete cancelado ({entity}) [esc]"
488 )));
489 cx.notify();
490 }
491 }))
492 .when_some(error_banner, |d, b| d.child(b))
493 .when_some(confirm_banner, |d, b| d.child(b))
494 .child(
495 div()
496 .flex()
497 .flex_row()
498 .flex_grow()
499 .child(sidebar)
500 .child(main_panel),
501 )
502 .when_some(toast_div, |d, t| d.child(t))
503 }
504}
505
506impl<B: MetaBackend> MetaApp<B> {
507 /// Renderea el banner modal de confirmación cuando hay un delete
508 /// pendiente. Devuelve `None` si no hay nada que confirmar.
509 ///
510 /// UX: banner amber prominente arriba de todo el contenido. No es
511 /// un overlay flotante (GPUI no expone z-index fácilmente sin
512 /// setup) — es un row del flex_col raíz, así que el contenido
513 /// debajo se desplaza unos pixels mientras está activo. Suficiente
514 /// para forzar al usuario a leer + click.
515 fn render_confirm_delete_banner(&self, cx: &mut Context<Self>) -> Option<gpui::Div> {
516 let (entity, id) = self.pending_delete.as_ref()?;
517 let entity_owned = entity.clone();
518 let id_owned = *id;
519 let entity_for_cancel = entity_owned.clone();
520 let id_short = short_uuid(&id_owned);
521 let entity_for_confirm = entity_owned.clone();
522
523 let banner_bg = gpui::rgb(0x4a3a1a);
524 let banner_text = gpui::rgb(0xf0e0a0);
525 let confirm_bg = gpui::rgb(0x6a2222);
526 let confirm_text = gpui::rgb(0xffd0d0);
527 let cancel_bg = gpui::rgb(0x2a2f38);
528 let cancel_text = gpui::rgb(0xc0c8d0);
529
530 Some(
531 div()
532 .flex()
533 .flex_row()
534 .items_center()
535 .gap(px(12.))
536 .px(px(12.))
537 .py(px(8.))
538 .bg(banner_bg)
539 .text_color(banner_text)
540 .text_size(px(12.))
541 .child(
542 div()
543 .flex_grow()
544 .flex()
545 .flex_col()
546 .child(format!("¿Borrar {entity_owned} {id_short}?"))
547 .child(
548 div()
549 .text_size(px(10.))
550 .text_color(gpui::rgb(0xc0a070))
551 .child("Esc para cancelar · click [Confirmar] para borrar"),
552 ),
553 )
554 .child(
555 div()
556 .id(SharedString::from(format!(
557 "confirm-del-cancel-{}",
558 id_owned
559 )))
560 .px(px(10.))
561 .py(px(4.))
562 .bg(cancel_bg)
563 .text_color(cancel_text)
564 .hover(|d| d.bg(gpui::rgb(0x3a3f48)))
565 .child("Cancelar")
566 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
567 this.pending_delete = None;
568 this.toast = Some(SharedString::from(format!(
569 "delete cancelado ({entity_for_cancel})"
570 )));
571 cx.notify();
572 })),
573 )
574 .child(
575 div()
576 .id(SharedString::from(format!(
577 "confirm-del-ok-{}",
578 id_owned
579 )))
580 .px(px(10.))
581 .py(px(4.))
582 .bg(confirm_bg)
583 .text_color(confirm_text)
584 .hover(|d| d.bg(gpui::rgb(0x8a2828)))
585 .child("Confirmar")
586 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
587 // Limpiar primero para que un fallo del
588 // commit no deje el banner colgado y el
589 // toast tape el banner.
590 this.pending_delete = None;
591 match this.commit_delete(&entity_for_confirm, id_owned) {
592 Ok(outcome) => {
593 let base = format!(
594 "borrado {entity_for_confirm} {}",
595 short_uuid(&id_owned)
596 );
597 this.toast = Some(append_compact_msg(
598 base,
599 outcome.post_status,
600 ));
601 }
602 Err(e) => {
603 this.toast = Some(SharedString::from(format!(
604 "error borrando: {e}"
605 )));
606 }
607 }
608 cx.notify();
609 })),
610 ),
611 )
612 }
613
614 fn render_sidebar(
615 &self,
616 cx: &mut Context<Self>,
617 panel: gpui::Rgba,
618 border: gpui::Rgba,
619 text: gpui::Rgba,
620 text_dim: gpui::Rgba,
621 accent_active: gpui::Rgba,
622 ) -> gpui::Div {
623 let mut sidebar = div()
624 .w(px(240.))
625 .h_full()
626 .bg(panel)
627 .border_r_1()
628 .border_color(border)
629 .flex()
630 .flex_col();
631
632 sidebar = sidebar.child(
633 div()
634 .px(px(12.))
635 .py(px(10.))
636 .text_color(text)
637 .text_size(px(13.))
638 .child("Nakui"),
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, |d| {
690 d.bg(gpui::rgb(0x232a36)).text_color(text)
691 })
692 .hover(|d| d.bg(gpui::rgb(0x1f2630)))
693 .child(label)
694 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
695 this.select_view(mod_idx, view_key.clone(), cx);
696 })),
697 );
698 }
699 }
700 sidebar
701 }
702
703 fn render_main(
704 &mut self,
705 cx: &mut Context<Self>,
706 panel: gpui::Rgba,
707 border: gpui::Rgba,
708 text: gpui::Rgba,
709 text_dim: gpui::Rgba,
710 accent: gpui::Rgba,
711 ) -> gpui::Div {
712 let main = div()
713 .flex_grow()
714 .h_full()
715 .bg(panel)
716 .flex()
717 .flex_col()
718 .p(px(16.));
719
720 let (mod_idx, view_key) = match &self.active {
721 Some(a) => (a.0, a.1.clone()),
722 None => {
723 return main.child(
724 div()
725 .text_color(text_dim)
726 .child("Seleccioná un menú a la izquierda."),
727 );
728 }
729 };
730
731 let view = match self
732 .modules
733 .get(mod_idx)
734 .and_then(|m| m.views.get(&view_key))
735 {
736 Some(v) => v.clone(),
737 None => {
738 return main.child(
739 div()
740 .text_color(text_dim)
741 .child(format!("Vista no encontrada: {view_key}")),
742 );
743 }
744 };
745
746 match view {
747 View::List(lv) => self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent),
748 View::Form(fv) => self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent),
749 }
750 }
751
752 #[allow(clippy::too_many_arguments)]
753 fn render_list(
754 &self,
755 cx: &mut Context<Self>,
756 mut main: gpui::Div,
757 lv: &ListView,
758 mod_idx: usize,
759 border: gpui::Rgba,
760 text: gpui::Rgba,
761 text_dim: gpui::Rgba,
762 accent: gpui::Rgba,
763 ) -> gpui::Div {
764 let mut header = div()
765 .flex()
766 .flex_row()
767 .items_center()
768 .gap(px(12.))
769 .mb(px(12.))
770 .child(
771 div()
772 .text_color(text)
773 .text_size(px(18.))
774 .child(lv.title.clone()),
775 );
776 for (idx, action) in lv.actions.iter().enumerate() {
777 let label = match action {
778 Action::OpenView { label, view } => {
779 label.clone().unwrap_or_else(|| format!("→ {view}"))
780 }
781 Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
782 Action::Morphism { name, .. } => format!("⚡ {name}"),
783 };
784 let action_clone = action.clone();
785 header = header.child(
786 div()
787 .id(SharedString::from(format!(
788 "list-action-{mod_idx}-{idx}"
789 )))
790 .px(px(10.))
791 .py(px(4.))
792 .bg(gpui::rgb(0x232a36))
793 .text_color(accent)
794 .text_size(px(11.))
795 .rounded(px(3.))
796 .hover(|d| d.bg(gpui::rgb(0x2c3540)))
797 .child(label)
798 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
799 this.apply_action(action_clone.clone(), cx);
800 })),
801 );
802 }
803 main = main.child(header);
804
805 let rows = self.list_rows(&lv.entity);
806 let total = rows.len();
807
808 let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
809 let mut col_header = div()
810 .flex()
811 .flex_row()
812 .py(px(6.))
813 .border_b_1()
814 .border_color(border)
815 .text_color(text_dim)
816 .text_size(px(11.));
817 for c in &lv.columns {
818 let frac = c.weight / total_weight;
819 col_header = col_header.child(
820 div()
821 .flex_grow()
822 .flex_basis(px(100. * frac))
823 .child(c.label.clone()),
824 );
825 }
826 col_header = col_header
827 .child(div().w(px(80.)).text_color(text_dim).child("id"))
828 .child(div().w(px(70.)).text_color(text_dim).child("acciones"));
829 main = main.child(col_header);
830
831 let entity_name = lv.entity.clone();
832 for (id, value) in &rows {
833 let id_copy = *id;
834 let entity_for_edit = entity_name.clone();
835 let entity_for_delete = entity_name.clone();
836 let mut row = div()
837 .flex()
838 .flex_row()
839 .py(px(6.))
840 .border_b_1()
841 .border_color(gpui::rgb(0x232a36))
842 .text_color(text)
843 .text_size(px(12.));
844 for c in &lv.columns {
845 let frac = c.weight / total_weight;
846 let v = lookup_field(value, &c.field);
847 row = row.child(
848 div()
849 .flex_grow()
850 .flex_basis(px(100. * frac))
851 .child(render_value(v)),
852 );
853 }
854 row = row.child(
855 div()
856 .w(px(80.))
857 .text_color(text_dim)
858 .text_size(px(11.))
859 .child(short_uuid(id)),
860 );
861 // Acciones: ✎ edit + ✕ delete por fila.
862 row = row.child(
863 div()
864 .w(px(70.))
865 .flex()
866 .flex_row()
867 .gap(px(4.))
868 .child(
869 div()
870 .id(SharedString::from(format!(
871 "row-edit-{mod_idx}-{id_copy}"
872 )))
873 .px(px(6.))
874 .text_color(accent)
875 .text_size(px(13.))
876 .hover(|d| d.bg(gpui::rgb(0x2c3540)))
877 .child("✎")
878 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
879 this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx);
880 })),
881 )
882 .child(
883 div()
884 .id(SharedString::from(format!(
885 "row-del-{mod_idx}-{id_copy}"
886 )))
887 .px(px(6.))
888 .text_color(gpui::rgb(0xd07070))
889 .text_size(px(13.))
890 .hover(|d| d.bg(gpui::rgb(0x4a2020)))
891 .child("✕")
892 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
893 // Marca para borrar en lugar de borrar
894 // directo: el modal de confirmación se
895 // renderea arriba en `render` y maneja
896 // confirm/cancel.
897 this.pending_delete =
898 Some((entity_for_delete.clone(), id_copy));
899 this.toast = None;
900 cx.notify();
901 })),
902 ),
903 );
904 main = main.child(row);
905 }
906
907 if rows.is_empty() {
908 main = main.child(
909 div()
910 .py(px(12.))
911 .text_color(text_dim)
912 .text_size(px(12.))
913 .child(format!("(sin {})", lv.entity)),
914 );
915 } else {
916 main = main.child(
917 div()
918 .mt(px(8.))
919 .text_color(text_dim)
920 .text_size(px(11.))
921 .child(format!("{total} fila(s)")),
922 );
923 }
924
925 main
926 }
927
928 /// Renderea el selector clickable de records existentes para un
929 /// FieldSpec con kind=EntityRef. Lista compacta debajo del input;
930 /// click en una opción setea el TextInput del field con el UUID
931 /// seleccionado. El item del UUID actualmente seleccionado (si
932 /// hay) se resalta con accent color.
933 #[allow(clippy::too_many_arguments)]
934 fn render_entity_ref_selector(
935 &self,
936 cx: &mut Context<Self>,
937 field_name: String,
938 target_entity: String,
939 text: gpui::Rgba,
940 text_dim: gpui::Rgba,
941 accent: gpui::Rgba,
942 ) -> gpui::Div {
943 let _ = text;
944 let rows = self.list_rows(&target_entity);
945 let current = self
946 .form_inputs
947 .get(&field_name)
948 .map(|inp| inp.read(&*cx).text().to_string())
949 .unwrap_or_default();
950
951 let mut container = div()
952 .mt(px(4.))
953 .pl(px(8.))
954 .border_l_2()
955 .border_color(gpui::rgb(0x2a2f38))
956 .flex()
957 .flex_col()
958 .gap(px(2.));
959
960 if rows.is_empty() {
961 return container.child(
962 div()
963 .px(px(6.))
964 .py(px(4.))
965 .text_color(text_dim)
966 .text_size(px(10.))
967 .child(format!(
968 "(sin {target_entity}: creá uno antes para referenciar)"
969 )),
970 );
971 }
972
973 container = container.child(
974 div()
975 .text_color(text_dim)
976 .text_size(px(10.))
977 .child(format!("Seleccioná un {target_entity}:")),
978 );
979
980 for (id, value) in &rows {
981 let label = human_label_for_record(value, id);
982 let id_str = id.to_string();
983 let is_selected = current == id_str;
984 let field_for_click = field_name.clone();
985 let id_for_click = id_str.clone();
986 container = container.child(
987 div()
988 .id(SharedString::from(format!(
989 "entity-ref-{field_name}-{id_str}"
990 )))
991 .px(px(6.))
992 .py(px(2.))
993 .text_size(px(11.))
994 .text_color(if is_selected { accent } else { text_dim })
995 .when(is_selected, |d| d.bg(gpui::rgb(0x232a36)))
996 .hover(|d| d.bg(gpui::rgb(0x1f2630)))
997 .child(label)
998 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
999 if let Some(input) = this.form_inputs.get(&field_for_click) {
1000 input.update(cx, |inp, cx| inp.set_text(id_for_click.clone(), cx));
1001 }
1002 cx.notify();
1003 })),
1004 );
1005 }
1006 container
1007 }
1008
1009 #[allow(clippy::too_many_arguments)]
1010 fn render_form(
1011 &self,
1012 cx: &mut Context<Self>,
1013 mut main: gpui::Div,
1014 fv: &FormView,
1015 mod_idx: usize,
1016 _border: gpui::Rgba,
1017 text: gpui::Rgba,
1018 text_dim: gpui::Rgba,
1019 accent: gpui::Rgba,
1020 ) -> gpui::Div {
1021 // En modo edit, el título refleja eso para que el user no
1022 // se confunda creyendo que hace alta nueva.
1023 let title = match self.editing.as_ref() {
1024 Some((e, id)) if e == &fv.entity => {
1025 format!("Editar {} {}", fv.entity, short_uuid(id))
1026 }
1027 _ => fv.title.clone(),
1028 };
1029 main = main.child(
1030 div()
1031 .text_color(text)
1032 .text_size(px(18.))
1033 .mb(px(12.))
1034 .child(title),
1035 );
1036 for f in &fv.fields {
1037 let label = if f.required {
1038 format!("{} *", f.label)
1039 } else {
1040 f.label.clone()
1041 };
1042
1043 let mut field_box = div().flex().flex_col().mb(px(10.)).child(
1044 div()
1045 .text_color(text_dim)
1046 .text_size(px(11.))
1047 .mb(px(2.))
1048 .child(label),
1049 );
1050
1051 // Mount del TextInput vivo (creado en select_view).
1052 if let Some(input) = self.form_inputs.get(&f.name) {
1053 field_box = field_box.child(input.clone());
1054 } else {
1055 // No debería pasar — select_view crea inputs por cada
1056 // field. Fallback display estático por seguridad.
1057 field_box = field_box.child(
1058 div()
1059 .px(px(8.))
1060 .py(px(6.))
1061 .bg(gpui::rgb(0x171a20))
1062 .text_color(text_dim)
1063 .child("(input no inicializado)"),
1064 );
1065 }
1066
1067 // Para EntityRef, agregamos un selector clickable de
1068 // records existentes debajo del TextInput. Click en una
1069 // opción setea el TextInput interno con el UUID; el
1070 // submit lee de ahí como cualquier otro field.
1071 if f.kind == FieldKind::EntityRef {
1072 if let Some(target_entity) = &f.ref_entity {
1073 field_box = field_box.child(self.render_entity_ref_selector(
1074 cx,
1075 f.name.clone(),
1076 target_entity.clone(),
1077 text,
1078 text_dim,
1079 accent,
1080 ));
1081 }
1082 }
1083
1084 if let Some(help) = &f.help {
1085 field_box = field_box.child(
1086 div()
1087 .mt(px(2.))
1088 .text_color(text_dim)
1089 .text_size(px(10.))
1090 .child(help.clone()),
1091 );
1092 }
1093 main = main.child(field_box);
1094 }
1095
1096 let editing_this = matches!(
1097 self.editing.as_ref(),
1098 Some((e, _)) if e == &fv.entity
1099 );
1100 let submit_label = match &fv.on_submit {
1101 Action::SeedEntity { entity, .. } => {
1102 if editing_this {
1103 format!("Guardar cambios en {entity}")
1104 } else {
1105 format!("Crear {entity}")
1106 }
1107 }
1108 Action::Morphism { name, .. } => format!("Ejecutar {name}"),
1109 Action::OpenView { label, view } => {
1110 label.clone().unwrap_or_else(|| format!("Ir a {view}"))
1111 }
1112 };
1113 let submit_action = fv.on_submit.clone();
1114 main = main.child(
1115 div().mt(px(12.)).child(
1116 div()
1117 .id(SharedString::from(format!("form-submit-{mod_idx}")))
1118 .px(px(12.))
1119 .py(px(6.))
1120 .bg(gpui::rgb(0x2c3540))
1121 .text_color(accent)
1122 .text_size(px(12.))
1123 .rounded(px(3.))
1124 .hover(|d| d.bg(gpui::rgb(0x3a4555)))
1125 .child(submit_label)
1126 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1127 this.apply_action(submit_action.clone(), cx);
1128 })),
1129 ),
1130 );
1131
1132 main = main.child(
1133 div()
1134 .mt(px(20.))
1135 .text_color(text_dim)
1136 .text_size(px(10.))
1137 .child(
1138 "Tip: click en el campo para enfocar; Enter no envía (todavía), \
1139 usá el botón. Backspace borra el último carácter.",
1140 ),
1141 );
1142 main
1143 }
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148 //! Tests del widget. Funciones puras (sin GPUI cx) sólo. Los
1149 //! tests de backend impl viven en el binario que provee el
1150 //! backend (ej: nakui-ui). Los helpers movidos a
1151 //! yahweh-meta-runtime se testean allí.
1152 use super::*;
1153 use serde_json::json;
1154
1155 #[test]
1156 fn lookup_field_simple_and_nested() {
1157 let v = json!({
1158 "name": "Acme",
1159 "address": { "city": "Bogotá", "country": "CO" }
1160 });
1161 assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
1162 assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá"));
1163 assert!(lookup_field(&v, "missing").is_none());
1164 assert!(lookup_field(&v, "address.zipcode").is_none());
1165 }
1166
1167 #[test]
1168 fn append_compact_msg_handles_both_branches() {
1169 // Sin compact: base solo, sin separador.
1170 let s = append_compact_msg("creado X".into(), None);
1171 assert_eq!(s.as_ref(), "creado X");
1172 // Con compact: concatena con "; ".
1173 let s = append_compact_msg(
1174 "creado X".into(),
1175 Some("auto-compact: snapshot @ seq 49".into()),
1176 );
1177 assert_eq!(s.as_ref(), "creado X; auto-compact: snapshot @ seq 49");
1178 }
1179
1180 #[test]
1181 fn format_seed_toast_distinguishes_create_update_no_change() {
1182 let id = Uuid::new_v4();
1183 let outcome_create = WriteOutcome {
1184 id: Some(id),
1185 changed: 1,
1186 post_status: None,
1187 };
1188 let toast = format_seed_toast("Customer", false, &outcome_create);
1189 assert!(toast.starts_with("creado Customer"));
1190
1191 let outcome_update = WriteOutcome {
1192 id: Some(id),
1193 changed: 3,
1194 post_status: None,
1195 };
1196 let toast = format_seed_toast("Customer", true, &outcome_update);
1197 assert!(toast.starts_with("actualizado Customer"));
1198 assert!(toast.contains("(3 campo(s))"));
1199
1200 let outcome_no_change = WriteOutcome::no_change(id);
1201 let toast = format_seed_toast("Customer", true, &outcome_no_change);
1202 assert!(toast.contains("sin cambios"));
1203 }
1204}