⎇ 86d06da020b21905887b8e689fe014b5c3cc6e8f
crates/modules/nahual/widgets/meta-form/src/lib.rs
History
Who touched what
raw
feat(nakui): Fase 1 del ERP — FieldKind Select + AutoId, seed inyecta id
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, human_label_for_record, parse_field_value,
31 render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text,
32 MetaBackend, WriteOutcome,
33};
34use nahual_meta_schema::{
35 Action, 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, ¶ms, 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(¤t, &obj);
384 let clear_fields = compute_clear_fields(¤t, &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(render_value(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 /// Chips clickables para un campo [`FieldKind::Select`]. El chip de
937 /// la opción elegida se resalta con accent. Click setea el
938 /// `TextInput` del field (de donde lee el submit), igual que el
939 /// selector de EntityRef.
940 fn render_select_chips(
941 &self,
942 cx: &mut Context<Self>,
943 field_name: String,
944 options: &[SelectOption],
945 text_dim: gpui::Hsla,
946 accent: gpui::Hsla,
947 ) -> gpui::Div {
948 let theme = Theme::global(cx);
949 let row_active = theme.bg_row_active;
950 let row_hover = theme.bg_row_hover;
951 let border = theme.border;
952 let current = self
953 .form_inputs
954 .get(&field_name)
955 .map(|inp| inp.read(&*cx).text().to_string())
956 .unwrap_or_default();
957
958 let mut row = div().mt(px(2.)).flex().flex_row().flex_wrap().gap(px(4.));
959
960 for opt in options {
961 let value = opt.value.clone();
962 let is_selected = current == value;
963 let field_for_click = field_name.clone();
964 let value_for_click = value.clone();
965 row = row.child(
966 div()
967 .id(SharedString::from(format!("select-{field_name}-{value}")))
968 .px(px(8.))
969 .py(px(3.))
970 .rounded(px(10.))
971 .border_1()
972 .border_color(if is_selected { accent } else { border })
973 .text_size(px(11.))
974 .text_color(if is_selected { accent } else { text_dim })
975 .when(is_selected, move |d| d.bg(row_active))
976 .hover(move |d| d.bg(row_hover))
977 .child(opt.display().to_string())
978 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
979 if let Some(input) = this.form_inputs.get(&field_for_click) {
980 input.update(cx, |inp, cx| inp.set_text(value_for_click.clone(), cx));
981 }
982 cx.notify();
983 })),
984 );
985 }
986 row
987 }
988
989 #[allow(clippy::too_many_arguments)]
990 fn render_entity_ref_selector(
991 &self,
992 cx: &mut Context<Self>,
993 field_name: String,
994 target_entity: String,
995 text: gpui::Hsla,
996 text_dim: gpui::Hsla,
997 accent: gpui::Hsla,
998 ) -> gpui::Div {
999 let _ = text;
1000 // Slots ornament para hover/selected del selector + border.
1001 let theme = Theme::global(cx);
1002 let row_active = theme.bg_row_active;
1003 let row_hover = theme.bg_row_hover;
1004 let rows = self.list_rows(&target_entity);
1005 let current = self
1006 .form_inputs
1007 .get(&field_name)
1008 .map(|inp| inp.read(&*cx).text().to_string())
1009 .unwrap_or_default();
1010
1011 let mut container = div()
1012 .mt(px(4.))
1013 .pl(px(8.))
1014 .border_l_2()
1015 .border_color(theme.border)
1016 .flex()
1017 .flex_col()
1018 .gap(px(2.));
1019
1020 if rows.is_empty() {
1021 return container.child(
1022 div()
1023 .px(px(6.))
1024 .py(px(4.))
1025 .text_color(text_dim)
1026 .text_size(px(10.))
1027 .child(format!(
1028 "(sin {target_entity}: creá uno antes para referenciar)"
1029 )),
1030 );
1031 }
1032
1033 container = container.child(
1034 div()
1035 .text_color(text_dim)
1036 .text_size(px(10.))
1037 .child(format!("Seleccioná un {target_entity}:")),
1038 );
1039
1040 for (id, value) in &rows {
1041 let label = human_label_for_record(value, id);
1042 let id_str = id.to_string();
1043 let is_selected = current == id_str;
1044 let field_for_click = field_name.clone();
1045 let id_for_click = id_str.clone();
1046 container = container.child(
1047 div()
1048 .id(SharedString::from(format!(
1049 "entity-ref-{field_name}-{id_str}"
1050 )))
1051 .px(px(6.))
1052 .py(px(2.))
1053 .text_size(px(11.))
1054 .text_color(if is_selected { accent } else { text_dim })
1055 .when(is_selected, move |d| d.bg(row_active))
1056 .hover(move |d| d.bg(row_hover))
1057 .child(label)
1058 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1059 if let Some(input) = this.form_inputs.get(&field_for_click) {
1060 input.update(cx, |inp, cx| inp.set_text(id_for_click.clone(), cx));
1061 }
1062 cx.notify();
1063 })),
1064 );
1065 }
1066 container
1067 }
1068
1069 #[allow(clippy::too_many_arguments)]
1070 fn render_form(
1071 &self,
1072 cx: &mut Context<Self>,
1073 mut main: gpui::Div,
1074 fv: &FormView,
1075 mod_idx: usize,
1076 _border: gpui::Hsla,
1077 text: gpui::Hsla,
1078 text_dim: gpui::Hsla,
1079 accent: gpui::Hsla,
1080 ) -> gpui::Div {
1081 // Slots ornament para el botón submit + bg de fallback inputs.
1082 let theme = Theme::global(cx);
1083 let submit_bg = theme.bg_button();
1084 let submit_hover = theme.bg_button_hover();
1085 let input_bg = theme.bg_input();
1086 // En modo edit, el título refleja eso para que el user no
1087 // se confunda creyendo que hace alta nueva.
1088 let title = match self.editing.as_ref() {
1089 Some((e, id)) if e == &fv.entity => {
1090 format!("Editar {} {}", fv.entity, short_uuid(id))
1091 }
1092 _ => fv.title.clone(),
1093 };
1094 main = main.child(
1095 div()
1096 .text_color(text)
1097 .text_size(px(18.))
1098 .mb(px(12.))
1099 .child(title),
1100 );
1101 for f in &fv.fields {
1102 let label = if f.required {
1103 format!("{} *", f.label)
1104 } else {
1105 f.label.clone()
1106 };
1107
1108 let mut field_box = div().flex().flex_col().mb(px(10.)).child(
1109 div()
1110 .text_color(text_dim)
1111 .text_size(px(11.))
1112 .mb(px(2.))
1113 .child(label),
1114 );
1115
1116 // Render según el kind. AutoId y Select NO montan el
1117 // TextInput editable —aunque sí existe en `form_inputs`, de
1118 // donde lee el submit—; presentan su propio control.
1119 match f.kind {
1120 FieldKind::AutoId => {
1121 // Read-only: el UUID autogenerado, sólo informativo.
1122 let val = self
1123 .form_inputs
1124 .get(&f.name)
1125 .map(|i| i.read(&*cx).text().to_string())
1126 .unwrap_or_default();
1127 field_box = field_box.child(
1128 div()
1129 .px(px(8.))
1130 .py(px(6.))
1131 .bg(input_bg)
1132 .text_color(text_dim)
1133 .text_size(px(11.))
1134 .child(format!("{val} · automático")),
1135 );
1136 }
1137 FieldKind::Select => {
1138 field_box = field_box.child(self.render_select_chips(
1139 cx,
1140 f.name.clone(),
1141 &f.options,
1142 text_dim,
1143 accent,
1144 ));
1145 }
1146 _ => {
1147 // Mount del TextInput vivo (creado en select_view).
1148 if let Some(input) = self.form_inputs.get(&f.name) {
1149 field_box = field_box.child(input.clone());
1150 } else {
1151 // No debería pasar — select_view crea inputs por
1152 // cada field. Fallback estático por seguridad.
1153 field_box = field_box.child(
1154 div()
1155 .px(px(8.))
1156 .py(px(6.))
1157 .bg(input_bg)
1158 .text_color(text_dim)
1159 .child("(input no inicializado)"),
1160 );
1161 }
1162 // EntityRef: selector clickable de records debajo
1163 // del input. Click setea el TextInput con el UUID.
1164 if f.kind == FieldKind::EntityRef {
1165 if let Some(target_entity) = &f.ref_entity {
1166 field_box = field_box.child(self.render_entity_ref_selector(
1167 cx,
1168 f.name.clone(),
1169 target_entity.clone(),
1170 text,
1171 text_dim,
1172 accent,
1173 ));
1174 }
1175 }
1176 }
1177 }
1178
1179 if let Some(help) = &f.help {
1180 field_box = field_box.child(
1181 div()
1182 .mt(px(2.))
1183 .text_color(text_dim)
1184 .text_size(px(10.))
1185 .child(help.clone()),
1186 );
1187 }
1188 main = main.child(field_box);
1189 }
1190
1191 let editing_this = matches!(
1192 self.editing.as_ref(),
1193 Some((e, _)) if e == &fv.entity
1194 );
1195 let submit_label = match &fv.on_submit {
1196 Action::SeedEntity { entity, .. } => {
1197 if editing_this {
1198 format!("Guardar cambios en {entity}")
1199 } else {
1200 format!("Crear {entity}")
1201 }
1202 }
1203 Action::Morphism { name, .. } => format!("Ejecutar {name}"),
1204 Action::OpenView { label, view } => {
1205 label.clone().unwrap_or_else(|| format!("Ir a {view}"))
1206 }
1207 };
1208 let submit_action = fv.on_submit.clone();
1209 main = main.child(
1210 div().mt(px(12.)).child(
1211 div()
1212 .id(SharedString::from(format!("form-submit-{mod_idx}")))
1213 .px(px(12.))
1214 .py(px(6.))
1215 .bg(submit_bg)
1216 .text_color(accent)
1217 .text_size(px(12.))
1218 .rounded(px(3.))
1219 .hover(move |d| d.bg(submit_hover))
1220 .child(submit_label)
1221 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1222 this.apply_action(submit_action.clone(), cx);
1223 })),
1224 ),
1225 );
1226
1227 main = main.child(
1228 div()
1229 .mt(px(20.))
1230 .text_color(text_dim)
1231 .text_size(px(10.))
1232 .child(
1233 "Tip: click en el campo para enfocar; Enter no envía (todavía), \
1234 usá el botón. Backspace borra el último carácter.",
1235 ),
1236 );
1237 main
1238 }
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243 //! Tests del widget. Funciones puras (sin GPUI cx) sólo. Los
1244 //! tests de backend impl viven en el binario que provee el
1245 //! backend (ej: nakui-ui). Los helpers movidos a
1246 //! nahual-meta-runtime se testean allí.
1247 use super::*;
1248 use serde_json::json;
1249
1250 #[test]
1251 fn lookup_field_simple_and_nested() {
1252 let v = json!({
1253 "name": "Acme",
1254 "address": { "city": "Bogotá", "country": "CO" }
1255 });
1256 assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
1257 assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá"));
1258 assert!(lookup_field(&v, "missing").is_none());
1259 assert!(lookup_field(&v, "address.zipcode").is_none());
1260 }
1261
1262 #[test]
1263 fn append_compact_msg_handles_both_branches() {
1264 // Sin compact: base solo, sin separador.
1265 let s = append_compact_msg("creado X".into(), None);
1266 assert_eq!(s.as_ref(), "creado X");
1267 // Con compact: concatena con "; ".
1268 let s = append_compact_msg(
1269 "creado X".into(),
1270 Some("auto-compact: snapshot @ seq 49".into()),
1271 );
1272 assert_eq!(s.as_ref(), "creado X; auto-compact: snapshot @ seq 49");
1273 }
1274
1275 #[test]
1276 fn format_seed_toast_distinguishes_create_update_no_change() {
1277 let id = Uuid::new_v4();
1278 let outcome_create = WriteOutcome {
1279 id: Some(id),
1280 changed: 1,
1281 post_status: None,
1282 };
1283 let toast = format_seed_toast("Customer", false, &outcome_create);
1284 assert!(toast.starts_with("creado Customer"));
1285
1286 let outcome_update = WriteOutcome {
1287 id: Some(id),
1288 changed: 3,
1289 post_status: None,
1290 };
1291 let toast = format_seed_toast("Customer", true, &outcome_update);
1292 assert!(toast.starts_with("actualizado Customer"));
1293 assert!(toast.contains("(3 campo(s))"));
1294
1295 let outcome_no_change = WriteOutcome::no_change(id);
1296 let toast = format_seed_toast("Customer", true, &outcome_no_change);
1297 assert!(toast.contains("sin cambios"));
1298 }
1299}