feat(nakui-ui): edit + delete de records (ciclo CRUD completo)
Cierra "no hay UI para editar/borrar" del commit anterior. Cada fila
de la lista gana dos botones (edit, delete); el form view se reusa
para alta y para edit; el delete es inline. Las mutaciones pasan por
LogEntry::Morphism con sus ops, asi el replay restaura el estado
correcto.
Cambios:
- MetaUi.editing: Option<(String, Uuid)> nuevo. Set al click ✎,
cleared al cambiar de view o tras submit.
- open_edit(mod_idx, entity, id, cx): setea editing, busca primer
Form view del modulo cuya entity matchee, navega ahi.
- select_view extendido: cuando carga un Form, si editing matchea y
el record existe, pre-llena cada input con value_to_input_text del
record (inverso de parse_field_value).
- commit_seed ramifica:
- Edit path: emite LogEntry::Morphism { name: "ui.edit_record",
ops: [Set per field] }. Aplica via store.apply.
- Seed path: alta nueva (comportamiento previo).
- commit_delete(entity, id): emite LogEntry::Morphism { name:
"ui.delete_record", ops: [Delete] } + apply.
- Render del form: titulo y submit label cambian segun editing
("Editar customer abc..." / "Guardar cambios").
- Render de la lista: dos columnas nuevas — id, acciones. Cada fila
con ✎ (edit, accent) y ✕ (delete, rojo) + hover states.
Coherencia con el modelo: todo cambio post-seed pasa por ops dentro
de Morphism. nakui-explorer muestra estos morphisms con sus ops en
la timeline.
Trade-offs:
- schema_hash: None sigue (legacy path) hasta Action::Morphism
wireé Manifest.
- Delete sin confirmacion (1 click).
- Edit sobreescribe todos los campos del form (no delta-only).
Tests: 3 nuevos. 10 totales:
- value_to_input_text_inverse_of_parse + round_trip — la propiedad
del pre-llenado.
- event_log_replay_handles_full_crud_cycle — E2E: seed + edit +
delete via log, replay desde cero deja store vacio. Replay parcial
deja valores editados.
Activacion:
NAKUI_EVENT_LOG=~/.nakui/state.jsonl \\
NAKUI_MODULES_DIR=examples/nakui-modules \\
cargo run -p nakui-ui
This commit is contained in:
@@ -6,6 +6,86 @@ ratio/diff ver `git show <sha>`.
|
|||||||
|
|
||||||
## 2026-05-09
|
## 2026-05-09
|
||||||
|
|
||||||
|
### feat(nakui-ui): edit + delete de records (ciclo CRUD completo)
|
||||||
|
Cierra "no hay UI para editar/borrar records existentes" del commit
|
||||||
|
anterior. Cada fila de la lista gana dos botones (✎ edit, ✕ delete);
|
||||||
|
el form view se reusa para alta y para edit; el delete es inline.
|
||||||
|
Las mutaciones pasan por `LogEntry::Morphism` con sus ops, así el
|
||||||
|
replay restaura el estado correcto.
|
||||||
|
|
||||||
|
Cambios:
|
||||||
|
|
||||||
|
- **`MetaUi.editing: Option<(String, Uuid)>`** nuevo. Set al click
|
||||||
|
en ✎; cleared al cambiar de view o tras submit exitoso.
|
||||||
|
- **`open_edit(mod_idx, entity, id, cx)`**: setea `editing`, busca la
|
||||||
|
primera Form view del módulo cuya `entity` matchee, navega ahí. Si
|
||||||
|
el módulo no tiene Form para esa entity → toast con error
|
||||||
|
("no hay form view para entity X").
|
||||||
|
- **`select_view`** extendido: cuando carga un Form, si `editing`
|
||||||
|
matchea esa entity y el record existe en el store, pre-llena cada
|
||||||
|
input con el valor del record (vía nuevo helper
|
||||||
|
`value_to_input_text` — inverso de `parse_field_value`).
|
||||||
|
- **`commit_seed`** ramifica:
|
||||||
|
- **Edit path** (cuando `editing.is_some()` y entity matchea):
|
||||||
|
emite `LogEntry::Morphism { name: "ui.edit_record", ops:
|
||||||
|
[Set { path, value } for each field], params: { entity, id,
|
||||||
|
fields } }`. Aplica al store via `apply(&ops)`.
|
||||||
|
- **Seed path** (alta nueva): comportamiento previo.
|
||||||
|
- **`commit_delete(entity, id)`**: emite `LogEntry::Morphism {
|
||||||
|
name: "ui.delete_record", ops: [Delete { entity, id }] }` + apply.
|
||||||
|
- **Render del form**: título cambia a "Editar customer abc12345"
|
||||||
|
cuando `editing` matchea; submit label cambia a "Guardar cambios
|
||||||
|
en customer".
|
||||||
|
- **Render de la lista**: dos columnas nuevas — "id" y "acciones".
|
||||||
|
Cada fila tiene ✎ (accent color, click → open_edit) y ✕ (rojo,
|
||||||
|
click → commit_delete). Hover states.
|
||||||
|
|
||||||
|
Ramificación visible en el event log:
|
||||||
|
```
|
||||||
|
{"kind":"seed","seq":0,"entity":"customer","id":"abc...","data":{"name":"Acme"}}
|
||||||
|
{"kind":"morphism","seq":1,"morphism":"ui.edit_record","ops":[
|
||||||
|
{"op":"set","path":{"entity":"customer","id":"abc...","field":"name"},
|
||||||
|
"value":"Acme S.A."}
|
||||||
|
]}
|
||||||
|
{"kind":"morphism","seq":2,"morphism":"ui.delete_record","ops":[
|
||||||
|
{"op":"delete","entity":"customer","id":"abc..."}
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
Coherente con el modelo de Nakui — todo cambio post-seed pasa por
|
||||||
|
ops dentro de Morphism. `nakui-explorer` muestra estos morphisms
|
||||||
|
con sus ops claros en su timeline.
|
||||||
|
|
||||||
|
Trade-offs documentados:
|
||||||
|
- **`schema_hash: None`** sigue para los morphism de la UI (legacy/
|
||||||
|
pre-versioning path) hasta que `Action::Morphism` cargue Manifest
|
||||||
|
schemas.
|
||||||
|
- **Delete sin confirmación**: 1 click, sin modal. Para MVP es OK
|
||||||
|
(los records son recuperables vía replay parcial), pero un futuro
|
||||||
|
iter agregaría confirmación.
|
||||||
|
- **Edit sobreescribe TODOS los campos del form**, no sólo los
|
||||||
|
cambiados — emite N ops Set, una por field. Adecuado para forms
|
||||||
|
chicos; para forms con muchos campos optimizar a delta-only.
|
||||||
|
|
||||||
|
Tests: 3 nuevos (10 totales en nakui-ui):
|
||||||
|
- `value_to_input_text_inverse_of_parse` y
|
||||||
|
`value_to_input_then_parse_round_trip` — la propiedad fundamental
|
||||||
|
del pre-llenado: text → parse devuelve el Value original.
|
||||||
|
- `event_log_replay_handles_full_crud_cycle` — E2E del log: escribe
|
||||||
|
Seed + Morphism(Set ops) + Morphism(Delete op), replay desde cero,
|
||||||
|
verifica que el store termina vacío (delete fue el último). Verifica
|
||||||
|
además que un replay parcial (sin el delete) deja los valores
|
||||||
|
editados.
|
||||||
|
|
||||||
|
Activación:
|
||||||
|
```sh
|
||||||
|
NAKUI_EVENT_LOG=~/.nakui/state.jsonl \
|
||||||
|
NAKUI_MODULES_DIR=examples/nakui-modules \
|
||||||
|
cargo run -p nakui-ui
|
||||||
|
# Crear un customer, click ✎ en su fila, modificar campos,
|
||||||
|
# "Guardar cambios". Click ✕ en otra fila para borrar.
|
||||||
|
# Cerrar y reabrir: el state persiste con todos los cambios.
|
||||||
|
```
|
||||||
|
|
||||||
### feat(nakui-ui): persistencia con event log + replay al startup
|
### feat(nakui-ui): persistencia con event log + replay al startup
|
||||||
Cierra "sin persistencia entre runs" del commit anterior. Cada
|
Cierra "sin persistencia entre runs" del commit anterior. Cada
|
||||||
`SeedEntity` se appendea al `nakui_core::event_log::EventLog` con
|
`SeedEntity` se appendea al `nakui_core::event_log::EventLog` con
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ use gpui::{
|
|||||||
div, prelude::*, px, App, Application, Bounds, ClickEvent, Context, Entity, IntoElement,
|
div, prelude::*, px, App, Application, Bounds, ClickEvent, Context, Entity, IntoElement,
|
||||||
Render, SharedString, Window, WindowBounds, WindowOptions,
|
Render, SharedString, Window, WindowBounds, WindowOptions,
|
||||||
};
|
};
|
||||||
|
use nakui_core::delta::{FieldOp, FieldPath};
|
||||||
use nakui_core::event_log::{replay_into, EventLog, LogEntry};
|
use nakui_core::event_log::{replay_into, EventLog, LogEntry};
|
||||||
use nakui_core::store::{MemoryStore, Store};
|
use nakui_core::store::{MemoryStore, Store};
|
||||||
use nakui_ui_schema::{
|
use nakui_ui_schema::{
|
||||||
@@ -79,6 +80,12 @@ struct MetaUi {
|
|||||||
/// Inputs vivos para el form actual: nombre del campo → TextInput.
|
/// Inputs vivos para el form actual: nombre del campo → TextInput.
|
||||||
/// Se reemplaza al cambiar de vista (drop de los anteriores).
|
/// Se reemplaza al cambiar de vista (drop de los anteriores).
|
||||||
form_inputs: BTreeMap<String, Entity<TextInput>>,
|
form_inputs: BTreeMap<String, Entity<TextInput>>,
|
||||||
|
/// Si está set, el próximo render del Form pre-llena los inputs
|
||||||
|
/// con los valores del record indicado, y `commit_seed` emite
|
||||||
|
/// un `LogEntry::Morphism { name: "ui.edit_record", ops: [Set...] }`
|
||||||
|
/// en lugar de un Seed nuevo. Limpia al cambiar de view o tras
|
||||||
|
/// submit exitoso.
|
||||||
|
editing: Option<(String, Uuid)>,
|
||||||
/// Mensaje toast al pie (success de submit, error de carga, etc.).
|
/// Mensaje toast al pie (success de submit, error de carga, etc.).
|
||||||
toast: Option<SharedString>,
|
toast: Option<SharedString>,
|
||||||
/// Si la carga de módulos falló al inicio.
|
/// Si la carga de módulos falló al inicio.
|
||||||
@@ -172,13 +179,16 @@ impl MetaUi {
|
|||||||
event_log,
|
event_log,
|
||||||
active,
|
active,
|
||||||
form_inputs: BTreeMap::new(),
|
form_inputs: BTreeMap::new(),
|
||||||
|
editing: None,
|
||||||
toast: initial_toast,
|
toast: initial_toast,
|
||||||
load_error,
|
load_error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cambia la vista activa. Si la nueva vista es un Form, crea
|
/// Cambia la vista activa. Si la nueva vista es un Form, crea
|
||||||
/// `TextInput` entities para cada field con su valor por defecto.
|
/// `TextInput` entities para cada field. Pre-llena con valores
|
||||||
|
/// del record si hay `editing` para esa entity; si no, usa el
|
||||||
|
/// `default` del schema.
|
||||||
/// Drop de los inputs anteriores ocurre al sobreescribir el map.
|
/// Drop de los inputs anteriores ocurre al sobreescribir el map.
|
||||||
fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context<Self>) {
|
fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context<Self>) {
|
||||||
self.active = Some((mod_idx, view_key.clone()));
|
self.active = Some((mod_idx, view_key.clone()));
|
||||||
@@ -186,16 +196,102 @@ impl MetaUi {
|
|||||||
self.form_inputs = BTreeMap::new();
|
self.form_inputs = BTreeMap::new();
|
||||||
if let Some(module) = self.modules.get(mod_idx) {
|
if let Some(module) = self.modules.get(mod_idx) {
|
||||||
if let Some(View::Form(form)) = module.views.get(&view_key) {
|
if let Some(View::Form(form)) = module.views.get(&view_key) {
|
||||||
|
// Snapshot del record si estamos editando esta entity.
|
||||||
|
let editing_record: Option<Value> = self.editing.as_ref().and_then(|(e, id)| {
|
||||||
|
if e == &form.entity {
|
||||||
|
let store = self.store.lock().ok()?;
|
||||||
|
store.load(e, *id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
for f in &form.fields {
|
for f in &form.fields {
|
||||||
let initial = f.default.clone().unwrap_or_default();
|
let initial = if let Some(rec) = &editing_record {
|
||||||
|
rec.get(&f.name)
|
||||||
|
.map(value_to_input_text)
|
||||||
|
.unwrap_or_else(|| f.default.clone().unwrap_or_default())
|
||||||
|
} else {
|
||||||
|
f.default.clone().unwrap_or_default()
|
||||||
|
};
|
||||||
let input = cx.new(|cx| TextInput::new(initial, cx));
|
let input = cx.new(|cx| TextInput::new(initial, cx));
|
||||||
self.form_inputs.insert(f.name.clone(), input);
|
self.form_inputs.insert(f.name.clone(), input);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Cambiar a una view que no es Form invalida el editing
|
||||||
|
// pendiente.
|
||||||
|
self.editing = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inicia un edit del record: setea `editing` y abre la primera
|
||||||
|
/// view de tipo Form del módulo (convención: la del schema).
|
||||||
|
fn open_edit(
|
||||||
|
&mut self,
|
||||||
|
mod_idx: usize,
|
||||||
|
entity: String,
|
||||||
|
id: Uuid,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.editing = Some((entity.clone(), id));
|
||||||
|
let form_view_key = self.modules.get(mod_idx).and_then(|m| {
|
||||||
|
m.views
|
||||||
|
.iter()
|
||||||
|
.find_map(|(key, v)| match v {
|
||||||
|
View::Form(form) if form.entity == entity => Some(key.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
match form_view_key {
|
||||||
|
Some(key) => self.select_view(mod_idx, key, cx),
|
||||||
|
None => {
|
||||||
|
self.toast = Some(SharedString::from(format!(
|
||||||
|
"no hay form view para entity '{entity}' en este módulo"
|
||||||
|
)));
|
||||||
|
self.editing = None;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borra un record. Emite Morphism con un FieldOp::Delete + lo
|
||||||
|
/// aplica al store via `apply` (no via remove directo, mantiene
|
||||||
|
/// el modelo de "todo cambio post-seed pasa por ops").
|
||||||
|
fn commit_delete(
|
||||||
|
&mut self,
|
||||||
|
entity: &str,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let ops = vec![FieldOp::Delete {
|
||||||
|
entity: entity.to_string(),
|
||||||
|
id,
|
||||||
|
}];
|
||||||
|
if let Some(log_arc) = self.event_log.as_ref() {
|
||||||
|
let mut log = log_arc
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "log mutex envenenado".to_string())?;
|
||||||
|
let seq = log.next_seq();
|
||||||
|
log.append(LogEntry::Morphism {
|
||||||
|
seq,
|
||||||
|
morphism: "ui.delete_record".into(),
|
||||||
|
inputs: Default::default(),
|
||||||
|
params: json!({ "entity": entity, "id": id.to_string() }),
|
||||||
|
ops: ops.clone(),
|
||||||
|
schema_hash: None,
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("append al log: {e}"))?;
|
||||||
|
}
|
||||||
|
let mut store = self
|
||||||
|
.store
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "store mutex envenenado".to_string())?;
|
||||||
|
store
|
||||||
|
.apply(&ops)
|
||||||
|
.map_err(|e| format!("apply Delete: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Aplica una acción (click en menú, botón de form, action de
|
/// Aplica una acción (click en menú, botón de form, action de
|
||||||
/// list). Mutaciones contra el store ocurren acá.
|
/// list). Mutaciones contra el store ocurren acá.
|
||||||
fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
|
fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
|
||||||
@@ -203,21 +299,30 @@ impl MetaUi {
|
|||||||
Some((i, _)) => *i,
|
Some((i, _)) => *i,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
// Snapshot del editing al entrar — si commit_seed modifica
|
||||||
|
// self.editing antes del toast, el mensaje refleja el modo
|
||||||
|
// correcto.
|
||||||
|
let was_editing = self.editing.is_some();
|
||||||
match action {
|
match action {
|
||||||
Action::OpenView { view, .. } => {
|
Action::OpenView { view, .. } => {
|
||||||
|
// Salir a otra view cancela el edit pendiente.
|
||||||
|
self.editing = None;
|
||||||
self.select_view(mod_idx, view, cx);
|
self.select_view(mod_idx, view, cx);
|
||||||
}
|
}
|
||||||
Action::SeedEntity { entity, next_view } => {
|
Action::SeedEntity { entity, next_view } => {
|
||||||
match self.commit_seed(mod_idx, &entity, cx) {
|
match self.commit_seed(mod_idx, &entity, cx) {
|
||||||
Ok(id) => {
|
Ok(id) => {
|
||||||
|
let action_label = if was_editing { "actualizado" } else { "creado" };
|
||||||
self.toast = Some(SharedString::from(format!(
|
self.toast = Some(SharedString::from(format!(
|
||||||
"creado {entity} {}",
|
"{action_label} {entity} {}",
|
||||||
short_uuid(&id)
|
short_uuid(&id)
|
||||||
)));
|
)));
|
||||||
|
// Limpia editing tras un commit exitoso —
|
||||||
|
// el record ya está sincronizado.
|
||||||
|
self.editing = None;
|
||||||
if let Some(v) = next_view {
|
if let Some(v) = next_view {
|
||||||
self.select_view(mod_idx, v, cx);
|
self.select_view(mod_idx, v, cx);
|
||||||
} else {
|
} else {
|
||||||
// Reset inputs al vacío para alta consecutiva.
|
|
||||||
for input in self.form_inputs.values() {
|
for input in self.form_inputs.values() {
|
||||||
input.update(cx, |inp, cx| inp.set_text("", cx));
|
input.update(cx, |inp, cx| inp.set_text("", cx));
|
||||||
}
|
}
|
||||||
@@ -270,25 +375,60 @@ impl MetaUi {
|
|||||||
.map_err(|e| format!("campo '{}': {e}", f.label))?;
|
.map_err(|e| format!("campo '{}': {e}", f.label))?;
|
||||||
obj.insert(f.name.clone(), value);
|
obj.insert(f.name.clone(), value);
|
||||||
}
|
}
|
||||||
let id = Uuid::new_v4();
|
// Ramificación: si `editing` está set para esta entity, es un
|
||||||
let data = Value::Object(obj);
|
// edit de un record existente — emitimos Morphism con un
|
||||||
|
// FieldOp::Set por cada campo del form (sobreescribe). Si no,
|
||||||
|
// es alta nueva — emitimos Seed con UUID fresco.
|
||||||
|
let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
|
||||||
|
|
||||||
|
if let Some((_, id)) = editing_match {
|
||||||
|
// EDIT path: Morphism { ui.edit_record, ops: [Set...] }
|
||||||
|
let ops: Vec<FieldOp> = obj
|
||||||
|
.iter()
|
||||||
|
.map(|(field, value)| FieldOp::Set {
|
||||||
|
path: FieldPath {
|
||||||
|
entity: entity.to_string(),
|
||||||
|
id,
|
||||||
|
field: field.clone(),
|
||||||
|
},
|
||||||
|
value: value.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// WAL: si hay event_log activo, escribir al log ANTES de mutar
|
|
||||||
// el store. Si el log falla, cancelamos toda la operación (el
|
|
||||||
// user reintenta). Si no hay log (degraded), seedea al store
|
|
||||||
// y aceptamos no-persistencia.
|
|
||||||
if let Some(log_arc) = self.event_log.as_ref() {
|
if let Some(log_arc) = self.event_log.as_ref() {
|
||||||
let mut log = log_arc
|
let mut log = log_arc
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| "log mutex envenenado".to_string())?;
|
.map_err(|_| "log mutex envenenado".to_string())?;
|
||||||
let seq = log.next_seq();
|
let seq = log.next_seq();
|
||||||
// schema_hash = None: ver doc del campo en
|
log.append(LogEntry::Morphism {
|
||||||
// nakui_core::event_log::LogEntry — "legacy/pre-versioning
|
seq,
|
||||||
// entries". Es el path correcto cuando el ingreso no
|
morphism: "ui.edit_record".into(),
|
||||||
// pasa por un Manifest+Executor (que sería el path de
|
inputs: Default::default(),
|
||||||
// morphism). Las altas administrativas vía la
|
params: json!({
|
||||||
// metainterfaz quedan flagged como pre-versioning hasta
|
"entity": entity,
|
||||||
// que Action::Morphism wireé el Manifest.
|
"id": id.to_string(),
|
||||||
|
"fields": Value::Object(obj.clone()),
|
||||||
|
}),
|
||||||
|
ops: ops.clone(),
|
||||||
|
schema_hash: None,
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("append al log: {e}"))?;
|
||||||
|
}
|
||||||
|
let mut store = self
|
||||||
|
.store
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "store mutex envenenado".to_string())?;
|
||||||
|
store.apply(&ops).map_err(|e| format!("apply Set: {e}"))?;
|
||||||
|
Ok(id)
|
||||||
|
} else {
|
||||||
|
// SEED path: alta nueva.
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let data = Value::Object(obj);
|
||||||
|
if let Some(log_arc) = self.event_log.as_ref() {
|
||||||
|
let mut log = log_arc
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "log mutex envenenado".to_string())?;
|
||||||
|
let seq = log.next_seq();
|
||||||
log.append(LogEntry::Seed {
|
log.append(LogEntry::Seed {
|
||||||
seq,
|
seq,
|
||||||
entity: entity.to_string(),
|
entity: entity.to_string(),
|
||||||
@@ -298,12 +438,12 @@ impl MetaUi {
|
|||||||
})
|
})
|
||||||
.map_err(|e| format!("append al log: {e}"))?;
|
.map_err(|e| format!("append al log: {e}"))?;
|
||||||
}
|
}
|
||||||
|
let mut store = self
|
||||||
if let Ok(mut store) = self.store.lock() {
|
.store
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "store mutex envenenado".to_string())?;
|
||||||
store.seed(entity, id, data);
|
store.seed(entity, id, data);
|
||||||
Ok(id)
|
Ok(id)
|
||||||
} else {
|
|
||||||
Err("store mutex envenenado".into())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +501,19 @@ fn render_value(v: Option<&Value>) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Conversión inversa a `parse_field_value`: del JSON al texto raw
|
||||||
|
/// que un input puede tomar y volver a parsearse igual al submit.
|
||||||
|
/// Usado para pre-llenar inputs en modo edit.
|
||||||
|
fn value_to_input_text(v: &Value) -> String {
|
||||||
|
match v {
|
||||||
|
Value::Null => String::new(),
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
Value::Bool(b) => if *b { "true" } else { "false" }.to_string(),
|
||||||
|
Value::Number(n) => n.to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn short_uuid(id: &Uuid) -> String {
|
fn short_uuid(id: &Uuid) -> String {
|
||||||
id.to_string().chars().take(8).collect()
|
id.to_string().chars().take(8).collect()
|
||||||
}
|
}
|
||||||
@@ -627,10 +780,16 @@ impl MetaUi {
|
|||||||
.child(c.label.clone()),
|
.child(c.label.clone()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
col_header = col_header.child(div().w(px(80.)).text_color(text_dim).child("id"));
|
col_header = col_header
|
||||||
|
.child(div().w(px(80.)).text_color(text_dim).child("id"))
|
||||||
|
.child(div().w(px(70.)).text_color(text_dim).child("acciones"));
|
||||||
main = main.child(col_header);
|
main = main.child(col_header);
|
||||||
|
|
||||||
|
let entity_name = lv.entity.clone();
|
||||||
for (id, value) in &rows {
|
for (id, value) in &rows {
|
||||||
|
let id_copy = *id;
|
||||||
|
let entity_for_edit = entity_name.clone();
|
||||||
|
let entity_for_delete = entity_name.clone();
|
||||||
let mut row = div()
|
let mut row = div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_row()
|
.flex_row()
|
||||||
@@ -656,6 +815,55 @@ impl MetaUi {
|
|||||||
.text_size(px(11.))
|
.text_size(px(11.))
|
||||||
.child(short_uuid(id)),
|
.child(short_uuid(id)),
|
||||||
);
|
);
|
||||||
|
// Acciones: ✎ edit + ✕ delete por fila.
|
||||||
|
row = row.child(
|
||||||
|
div()
|
||||||
|
.w(px(70.))
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.gap(px(4.))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.id(SharedString::from(format!(
|
||||||
|
"row-edit-{mod_idx}-{id_copy}"
|
||||||
|
)))
|
||||||
|
.px(px(6.))
|
||||||
|
.text_color(accent)
|
||||||
|
.text_size(px(13.))
|
||||||
|
.hover(|d| d.bg(gpui::rgb(0x2c3540)))
|
||||||
|
.child("✎")
|
||||||
|
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
||||||
|
this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.id(SharedString::from(format!(
|
||||||
|
"row-del-{mod_idx}-{id_copy}"
|
||||||
|
)))
|
||||||
|
.px(px(6.))
|
||||||
|
.text_color(gpui::rgb(0xd07070))
|
||||||
|
.text_size(px(13.))
|
||||||
|
.hover(|d| d.bg(gpui::rgb(0x4a2020)))
|
||||||
|
.child("✕")
|
||||||
|
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
||||||
|
match this.commit_delete(&entity_for_delete, id_copy) {
|
||||||
|
Ok(()) => {
|
||||||
|
this.toast = Some(SharedString::from(format!(
|
||||||
|
"borrado {entity_for_delete} {}",
|
||||||
|
short_uuid(&id_copy)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.toast = Some(SharedString::from(format!(
|
||||||
|
"error borrando: {e}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
main = main.child(row);
|
main = main.child(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,12 +900,20 @@ impl MetaUi {
|
|||||||
text_dim: gpui::Rgba,
|
text_dim: gpui::Rgba,
|
||||||
accent: gpui::Rgba,
|
accent: gpui::Rgba,
|
||||||
) -> gpui::Div {
|
) -> gpui::Div {
|
||||||
|
// En modo edit, el título refleja eso para que el user no
|
||||||
|
// se confunda creyendo que hace alta nueva.
|
||||||
|
let title = match self.editing.as_ref() {
|
||||||
|
Some((e, id)) if e == &fv.entity => {
|
||||||
|
format!("Editar {} {}", fv.entity, short_uuid(id))
|
||||||
|
}
|
||||||
|
_ => fv.title.clone(),
|
||||||
|
};
|
||||||
main = main.child(
|
main = main.child(
|
||||||
div()
|
div()
|
||||||
.text_color(text)
|
.text_color(text)
|
||||||
.text_size(px(18.))
|
.text_size(px(18.))
|
||||||
.mb(px(12.))
|
.mb(px(12.))
|
||||||
.child(fv.title.clone()),
|
.child(title),
|
||||||
);
|
);
|
||||||
for f in &fv.fields {
|
for f in &fv.fields {
|
||||||
let label = if f.required {
|
let label = if f.required {
|
||||||
@@ -742,8 +958,18 @@ impl MetaUi {
|
|||||||
main = main.child(field_box);
|
main = main.child(field_box);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let editing_this = matches!(
|
||||||
|
self.editing.as_ref(),
|
||||||
|
Some((e, _)) if e == &fv.entity
|
||||||
|
);
|
||||||
let submit_label = match &fv.on_submit {
|
let submit_label = match &fv.on_submit {
|
||||||
Action::SeedEntity { entity, .. } => format!("Crear {entity}"),
|
Action::SeedEntity { entity, .. } => {
|
||||||
|
if editing_this {
|
||||||
|
format!("Guardar cambios en {entity}")
|
||||||
|
} else {
|
||||||
|
format!("Crear {entity}")
|
||||||
|
}
|
||||||
|
}
|
||||||
Action::Morphism { name, .. } => format!("Ejecutar {name}"),
|
Action::Morphism { name, .. } => format!("Ejecutar {name}"),
|
||||||
Action::OpenView { label, view } => {
|
Action::OpenView { label, view } => {
|
||||||
label.clone().unwrap_or_else(|| format!("Ir a {view}"))
|
label.clone().unwrap_or_else(|| format!("Ir a {view}"))
|
||||||
@@ -833,6 +1059,40 @@ mod tests {
|
|||||||
assert_eq!(render_value(Some(&json!(42))), "42");
|
assert_eq!(render_value(Some(&json!(42))), "42");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_to_input_text_inverse_of_parse() {
|
||||||
|
// text → text
|
||||||
|
assert_eq!(value_to_input_text(&json!("hola")), "hola");
|
||||||
|
// bool → "true"/"false" (parse_field_value lo acepta)
|
||||||
|
assert_eq!(value_to_input_text(&json!(true)), "true");
|
||||||
|
assert_eq!(value_to_input_text(&json!(false)), "false");
|
||||||
|
// number → string
|
||||||
|
assert_eq!(value_to_input_text(&json!(42)), "42");
|
||||||
|
assert_eq!(value_to_input_text(&json!(3.14)), "3.14");
|
||||||
|
// null → ""
|
||||||
|
assert_eq!(value_to_input_text(&json!(null)), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_to_input_then_parse_round_trip() {
|
||||||
|
// El round-trip es la propiedad fundamental: edit → text →
|
||||||
|
// parse → mismo Value (modulo casts numéricos).
|
||||||
|
let cases = vec![
|
||||||
|
(FieldKind::Text, json!("hola")),
|
||||||
|
(FieldKind::Boolean, json!(true)),
|
||||||
|
(FieldKind::Boolean, json!(false)),
|
||||||
|
(FieldKind::Number, json!(42)),
|
||||||
|
];
|
||||||
|
for (kind, original) in cases {
|
||||||
|
let text = value_to_input_text(&original);
|
||||||
|
let parsed = parse_field_value(kind, &text).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
parsed, original,
|
||||||
|
"round-trip text→parse falló para {original:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// E2E mínimo del WAL: armamos un log a mano con dos seeds,
|
/// E2E mínimo del WAL: armamos un log a mano con dos seeds,
|
||||||
/// abrimos con `EventLog::open` + `replay_into`, y verificamos
|
/// abrimos con `EventLog::open` + `replay_into`, y verificamos
|
||||||
/// que el `MemoryStore` queda con esos records aplicados.
|
/// que el `MemoryStore` queda con esos records aplicados.
|
||||||
@@ -892,4 +1152,118 @@ mod tests {
|
|||||||
|
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// E2E del ciclo CRUD vía log:
|
||||||
|
/// 1. Seed un record.
|
||||||
|
/// 2. Morphism con Set ops (edit) — sobreescribe campos.
|
||||||
|
/// 3. Morphism con Delete op — borra el record.
|
||||||
|
/// 4. Replay desde cero: el store queda como tras el delete (vacío).
|
||||||
|
#[test]
|
||||||
|
fn event_log_replay_handles_full_crud_cycle() {
|
||||||
|
use nakui_core::delta::{FieldOp, FieldPath};
|
||||||
|
use nakui_core::event_log::{replay_into, EventLog, LogEntry};
|
||||||
|
use nakui_core::store::{MemoryStore, Store};
|
||||||
|
|
||||||
|
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
let path = tmp.path().to_path_buf();
|
||||||
|
drop(tmp);
|
||||||
|
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
|
||||||
|
// 1. Escribir 3 entries: seed, edit, delete.
|
||||||
|
{
|
||||||
|
let mut log = EventLog::open(&path).unwrap();
|
||||||
|
log.append(LogEntry::Seed {
|
||||||
|
seq: 0,
|
||||||
|
entity: "customer".into(),
|
||||||
|
id,
|
||||||
|
data: json!({"name": "Acme", "active": true}),
|
||||||
|
schema_hash: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
log.append(LogEntry::Morphism {
|
||||||
|
seq: 1,
|
||||||
|
morphism: "ui.edit_record".into(),
|
||||||
|
inputs: Default::default(),
|
||||||
|
params: json!({}),
|
||||||
|
ops: vec![
|
||||||
|
FieldOp::Set {
|
||||||
|
path: FieldPath {
|
||||||
|
entity: "customer".into(),
|
||||||
|
id,
|
||||||
|
field: "name".into(),
|
||||||
|
},
|
||||||
|
value: json!("Acme S.A."),
|
||||||
|
},
|
||||||
|
FieldOp::Set {
|
||||||
|
path: FieldPath {
|
||||||
|
entity: "customer".into(),
|
||||||
|
id,
|
||||||
|
field: "active".into(),
|
||||||
|
},
|
||||||
|
value: json!(false),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
schema_hash: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
log.append(LogEntry::Morphism {
|
||||||
|
seq: 2,
|
||||||
|
morphism: "ui.delete_record".into(),
|
||||||
|
inputs: Default::default(),
|
||||||
|
params: json!({}),
|
||||||
|
ops: vec![FieldOp::Delete {
|
||||||
|
entity: "customer".into(),
|
||||||
|
id,
|
||||||
|
}],
|
||||||
|
schema_hash: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Replay desde cero — debe terminar con store vacío
|
||||||
|
// (el delete fue el último op).
|
||||||
|
let log = EventLog::open(&path).unwrap();
|
||||||
|
let mut store = MemoryStore::new();
|
||||||
|
replay_into(&log, &mut store).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.load("customer", id),
|
||||||
|
None,
|
||||||
|
"tras seed + edit + delete, el record no debería existir"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Sanity: si paramos en seq=1 (snapshot post-edit), el
|
||||||
|
// record debería tener los valores editados.
|
||||||
|
// (Construimos un store fresh y aplicamos sólo seq 0 y 1
|
||||||
|
// a mano para verificar.)
|
||||||
|
let mut store_partial = MemoryStore::new();
|
||||||
|
store_partial.seed("customer", id, json!({"name": "Acme", "active": true}));
|
||||||
|
store_partial
|
||||||
|
.apply(&[
|
||||||
|
FieldOp::Set {
|
||||||
|
path: FieldPath {
|
||||||
|
entity: "customer".into(),
|
||||||
|
id,
|
||||||
|
field: "name".into(),
|
||||||
|
},
|
||||||
|
value: json!("Acme S.A."),
|
||||||
|
},
|
||||||
|
FieldOp::Set {
|
||||||
|
path: FieldPath {
|
||||||
|
entity: "customer".into(),
|
||||||
|
id,
|
||||||
|
field: "active".into(),
|
||||||
|
},
|
||||||
|
value: json!(false),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store_partial.load("customer", id),
|
||||||
|
Some(json!({"name": "Acme S.A.", "active": false})),
|
||||||
|
"tras seed + edit, el record debería tener los nuevos valores"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user