tawasuyu

tawasuyu/nakui

> Excel-style reactive engine, on solid principles — exact Decimal, topological cascade, WAL, time-travel — in Rust, with a Llimphi UI.

Rust 82Markdown 14JSON 12TOML 101471 Definiciones

⎇ 7041e0499f8449ae8d7bba2a65695d82cef05a9e 01_yachay/nakui/nakui-ui-llimphi/src/form.rs Rust · 292 líneas · 10.4 KB Historia Quién tocó qué crudo

feat: nakui standalone — front-door ERP/Hoja/Grafo sobre Llimphi 7041e0499 · Sergio ·

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(&current, &obj);
172                        let clear = compute_clear_fields(&current, &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}