tawasuyu/nakui
> Excel-style reactive engine, on solid principles — exact Decimal, topological cascade, WAL, time-travel — in Rust, with a Llimphi UI.
⎇ 7041e0499f8449ae8d7bba2a65695d82cef05a9e
01_yachay/nakui/nakui-ui-llimphi/src/form.rs
Historia
Quién tocó qué
crudo
feat: nakui standalone — front-door ERP/Hoja/Grafo sobre Llimphi
1use super::*;
2
3/// Tras cambiar de módulo/menú: si la vista activa es un `Form`, abre el
4/// form fresco (así clickear "Nuevo" en el menú muestra el formulario).
5pub(crate) fn sync_form_to_menu(m: &mut Model) {
6 let (Some(mod_idx), Some(menu_idx)) = (m.selected_module, m.selected_menu) else {
7 return;
8 };
9 let Some(module) = m.modules.get(mod_idx) else {
10 return;
11 };
12 let Some(item) = module.menu.get(menu_idx) else {
13 return;
14 };
15 if let Some(ModuleView::Form(fv)) = module.views.get(&item.view) {
16 m.form = Some(build_form(mod_idx, fv, None));
17 }
18}
19
20/// Localiza el primer `Form` view de un módulo cuya entity coincide.
21pub(crate) fn find_form_view<'a>(module: &'a Module, entity: &str) -> Option<&'a FormView> {
22 module.views.values().find_map(|v| match v {
23 ModuleView::Form(fv) if fv.entity == entity => Some(fv),
24 _ => None,
25 })
26}
27
28/// Construye un `FormState` desde un `FormView`. `editing` pre-rellena
29/// los inputs desde un record existente; en alta, los `AutoId` se
30/// rellenan con un UUID nuevo y el resto con su `default`.
31pub(crate) fn build_form(module_idx: usize, fv: &FormView, editing: Option<(Uuid, Value)>) -> FormState {
32 let fields = fv
33 .fields
34 .iter()
35 .map(|fs| {
36 let mut input = TextInputState::new();
37 let raw = match &editing {
38 Some((_, rec)) => rec
39 .get(&fs.name)
40 .map(value_to_raw)
41 .unwrap_or_default(),
42 None => match fs.kind {
43 FieldKind::AutoId => Uuid::new_v4().to_string(),
44 FieldKind::Boolean => fs.default.clone().unwrap_or_else(|| "false".into()),
45 _ => fs.default.clone().unwrap_or_default(),
46 },
47 };
48 input.set_text(raw);
49 FieldRuntime {
50 spec: fs.clone(),
51 input,
52 }
53 })
54 .collect();
55
56 FormState {
57 module_idx,
58 entity: fv.entity.clone(),
59 title: fv.title.clone(),
60 on_submit: fv.on_submit.clone(),
61 fields,
62 editing: editing.as_ref().map(|(id, _)| *id),
63 original: editing.map(|(_, v)| v),
64 focused: None,
65 error: None,
66 }
67}
68
69/// Representación cruda (string) de un valor JSON para precargar un input.
70pub(crate) fn value_to_raw(v: &Value) -> String {
71 match v {
72 Value::String(s) => s.clone(),
73 Value::Bool(b) => b.to_string(),
74 Value::Number(n) => n.to_string(),
75 Value::Null => String::new(),
76 other => other.to_string(),
77 }
78}
79
80pub(crate) fn is_text_field(kind: FieldKind) -> bool {
81 matches!(
82 kind,
83 FieldKind::Text | FieldKind::Multiline | FieldKind::Number | FieldKind::Date
84 )
85}
86
87/// Ejecuta el submit del form activo contra el backend. Espeja
88/// `commit_seed` / `commit_morphism` del meta-form GPUI borrado:
89/// valida required, parsea por kind, valida `EntityRef`s, y ramifica en
90/// edición (`update` con delta) vs alta (`seed`/`morphism`).
91///
92/// Saca el form del modelo con `take()` para no aliasar `m` mientras
93/// tiene tomado el guard del backend; si algo falla, lo reinserta con el
94/// error puesto para que la UI lo muestre.
95pub(crate) fn submit_form(m: &mut Model) {
96 let Some(mut form) = m.form.take() else {
97 return;
98 };
99
100 // 1. Recolectar y parsear los fields.
101 let mut obj = serde_json::Map::new();
102 let mut to_clear: Vec<String> = Vec::new();
103 let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new();
104 let mut by_name: BTreeMap<String, String> = BTreeMap::new();
105 let mut parse_error: Option<String> = None;
106
107 for fr in &form.fields {
108 let raw = fr.raw();
109 by_name.insert(fr.spec.name.clone(), raw.clone());
110
111 if fr.spec.required && raw.trim().is_empty() && fr.spec.kind != FieldKind::AutoId {
112 parse_error = Some(format!("campo '{}' es obligatorio", fr.spec.label));
113 break;
114 }
115 if raw.is_empty() && !fr.spec.required {
116 to_clear.push(fr.spec.name.clone());
117 continue;
118 }
119 let value = match parse_field_value(fr.spec.kind, &raw) {
120 Ok(v) => v,
121 Err(e) => {
122 parse_error = Some(format!("campo '{}': {e}", fr.spec.label));
123 break;
124 }
125 };
126 if fr.spec.kind == FieldKind::EntityRef {
127 if let (Some(target), Some(uuid_str)) = (&fr.spec.ref_entity, value.as_str()) {
128 if let Ok(id) = Uuid::parse_str(uuid_str) {
129 entity_refs.push((fr.spec.label.clone(), target.clone(), id));
130 }
131 }
132 }
133 obj.insert(fr.spec.name.clone(), value);
134 }
135
136 if let Some(e) = parse_error {
137 form.error = Some(e);
138 m.form = Some(form);
139 return;
140 }
141
142 // 2. Datos derivados (sin tocar `form` durante el lock del backend).
143 let module_id = m
144 .modules
145 .get(form.module_idx)
146 .map(|md| md.id.clone())
147 .unwrap_or_default();
148 let entity = form.entity.clone();
149 let editing = form.editing;
150 let original = form.original.clone();
151 let on_submit = form.on_submit.clone();
152 let specs: BTreeMap<String, FieldSpec> = form
153 .fields
154 .iter()
155 .map(|f| (f.spec.name.clone(), f.spec.clone()))
156 .collect();
157
158 // 3. Resolver contra el backend (lock una sola vez).
159 let result: Result<WriteOutcome, String> = match m.backend.lock() {
160 Ok(mut backend) => {
161 let refs_ok: Result<(), String> = if entity_refs.is_empty() {
162 Ok(())
163 } else {
164 validate_entity_refs(|e, id| backend.load_record(e, id), &entity_refs)
165 };
166 match refs_ok {
167 Err(e) => Err(e),
168 Ok(()) => {
169 if let Some(id) = editing {
170 let current = original.unwrap_or(Value::Null);
171 let set = compute_field_delta(¤t, &obj);
172 let clear = compute_clear_fields(¤t, &to_clear);
173 backend.update(&entity, id, set, clear)
174 } else {
175 match &on_submit {
176 Action::SeedEntity { entity: e, .. } => backend.seed(e, obj),
177 Action::Morphism {
178 name,
179 inputs,
180 params,
181 ..
182 } => commit_morphism(
183 &mut backend,
184 &module_id,
185 name,
186 inputs,
187 params,
188 &by_name,
189 &specs,
190 ),
191 Action::OpenView { .. } => {
192 Err("on_submit OpenView no crea ni edita records".into())
193 }
194 }
195 }
196 }
197 }
198 }
199 Err(_) => Err("backend lock envenenado".into()),
200 };
201
202 // 4. Toast + navegación.
203 match result {
204 Ok(outcome) => {
205 let verb = if editing.is_some() { "guardado" } else { "creado" };
206 let mut text = match outcome.changed {
207 0 => format!("{entity}: sin cambios"),
208 _ => format!("{entity} {verb} ✓"),
209 };
210 if let Some(post) = outcome.post_status {
211 text = format!("{text} · {post}");
212 }
213 m.toast = Some(Toast {
214 kind: BannerKind::Success,
215 text,
216 });
217 // `form` queda consumido (no reinsertado): cerramos la sesión.
218 navigate_next_view(m, &on_submit);
219 }
220 Err(e) => {
221 form.error = Some(e);
222 m.form = Some(form);
223 }
224 }
225}
226
227/// Resuelve inputs (role→field→UUID) y params (fields → JSON) y delega
228/// al backend. Espejo de `commit_morphism` del widget GPUI.
229pub(crate) fn commit_morphism(
230 backend: &mut NakuiBackend,
231 module_id: &str,
232 name: &str,
233 inputs_map: &BTreeMap<String, String>,
234 params_fields: &[String],
235 by_name: &BTreeMap<String, String>,
236 specs: &BTreeMap<String, FieldSpec>,
237) -> Result<WriteOutcome, String> {
238 // Inputs: cada (role, field) → parsear el value del field como UUID.
239 let mut inputs: BTreeMap<String, Uuid> = BTreeMap::new();
240 for (role, field_name) in inputs_map {
241 let raw = by_name
242 .get(field_name)
243 .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
244 let id = Uuid::parse_str(raw.trim()).map_err(|_| {
245 format!("input '{role}' (field '{field_name}'): '{raw}' no es UUID válido")
246 })?;
247 inputs.insert(role.clone(), id);
248 }
249
250 // Params: lista explícita, o todos los fields que no son inputs.
251 let input_fields: BTreeSet<&String> = inputs_map.values().collect();
252 let field_iter: Vec<String> = if params_fields.is_empty() {
253 by_name
254 .keys()
255 .filter(|k| !input_fields.contains(*k))
256 .cloned()
257 .collect()
258 } else {
259 params_fields.to_vec()
260 };
261
262 let mut params_obj = serde_json::Map::new();
263 for field_name in field_iter {
264 let raw = by_name.get(&field_name).cloned().unwrap_or_default();
265 let spec = specs.get(&field_name);
266 let value = resolve_param_value(&field_name, &raw, spec)?;
267 params_obj.insert(field_name, value);
268 }
269
270 backend.morphism(module_id, name, inputs, Value::Object(params_obj))
271}
272
273/// Tras un submit exitoso, salta al `next_view` declarado en la acción
274/// (típicamente `"list"`), seleccionando ese ítem del menú del módulo.
275pub(crate) fn navigate_next_view(m: &mut Model, action: &Action) {
276 let next = match action {
277 Action::SeedEntity { next_view, .. } => next_view.clone(),
278 Action::Morphism { next_view, .. } => next_view.clone(),
279 Action::OpenView { view, .. } => Some(view.clone()),
280 };
281 let Some(view_key) = next else {
282 return;
283 };
284 let Some(mod_idx) = m.selected_module else {
285 return;
286 };
287 if let Some(module) = m.modules.get(mod_idx) {
288 if let Some(i) = module.menu.iter().position(|it| it.view == view_key) {
289 m.selected_menu = Some(i);
290 }
291 }
292}