feat(nakui-core,nakui-ui): FieldOp::Clear — borrar values vía form vacío
Nueva variante semántica del kernel: Clear { path } remueve la key
del record, distinta de Set { value: Null } (que deja la key con
valor literal null). Habilita "limpiar" un field optional vaciando
el input en la UI.
nakui-core:
- delta::FieldOp::Clear + simulate_on + capability_token (mismo
shape que Set: "entity.field").
- MemoryStore::apply_dry_run y apply: Set/Clear comparten
pre-condition (record existe + es objeto). Clear de field
ausente = no-op silencioso.
- SurrealStore: equivalente con `UPDATE ... UNSET <field>`.
- Executor capability check: Set/Clear comparten match.
- Conservation rules NO consideran Clear (sólo Set) — documentado
como morphism-author responsibility.
nakui-ui:
- commit_seed acumula `to_clear: Vec<String>` con optional empties
en lugar de `continue` silencioso.
- EDIT branch: nuevo helper compute_clear_fields filtra a sólo los
fields con current value non-null. Combina Set + Clear ops.
NoChange ahora requiere ambos vacíos. Log entry incluye
`cleared: [...]` sólo si non-empty. Updated.changed cuenta
sets+clears.
Tests: +7 en nakui-core (4 store + 3 delta), +3 en nakui-ui.
Suites: 34/34 nakui-core, 40/40 nakui-ui. Workspace build verde.
E2E del morphism real intacto.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -700,6 +700,11 @@ impl MetaUi {
|
||||
None => return Err("ninguna vista activa".into()),
|
||||
};
|
||||
let mut obj = serde_json::Map::new();
|
||||
// Fields que el form deja vacíos y son optional: candidatos a
|
||||
// `FieldOp::Clear` en el path de EDIT (sólo se emiten si el
|
||||
// current store value tiene algo en ese key). En el SEED path
|
||||
// simplemente no se incluyen, igual que antes.
|
||||
let mut to_clear: Vec<String> = Vec::new();
|
||||
for f in &spec_fields {
|
||||
let raw = self
|
||||
.form_inputs
|
||||
@@ -710,6 +715,7 @@ impl MetaUi {
|
||||
return Err(format!("campo '{}' es obligatorio", f.label));
|
||||
}
|
||||
if raw.is_empty() && !f.required {
|
||||
to_clear.push(f.name.clone());
|
||||
continue;
|
||||
}
|
||||
let value = parse_field_value(f.kind, &raw)
|
||||
@@ -724,16 +730,10 @@ impl MetaUi {
|
||||
|
||||
if let Some((_, id)) = editing_match {
|
||||
// EDIT path: delta-only. Cargar el record actual del store
|
||||
// y emitir `FieldOp::Set` sólo para los campos cuyo valor
|
||||
// nuevo difiere del actual. Si nada cambió, ningún log
|
||||
// entry y ningún apply — el toast lo refleja.
|
||||
//
|
||||
// Nota: campos que el form deja vacíos *no* se incluyen
|
||||
// en `obj` (skip arriba), así que no se pueden "limpiar"
|
||||
// borrando el input. Esto es consistente con el comportamiento
|
||||
// pre-delta y con el seed path. Para clearear hay que
|
||||
// declarar el field como required y forzar un value, o
|
||||
// implementar un FieldOp::Clear futuro.
|
||||
// y emitir `FieldOp::Set` por cada campo cuyo valor nuevo
|
||||
// difiere del actual + `FieldOp::Clear` por cada optional
|
||||
// empty cuyo current tenía un valor non-null. Si nada
|
||||
// cambió, ningún log entry y ningún apply.
|
||||
let current: Value = {
|
||||
let store = self
|
||||
.store
|
||||
@@ -741,15 +741,15 @@ impl MetaUi {
|
||||
.map_err(|_| "store mutex envenenado".to_string())?;
|
||||
store.load(entity, id).unwrap_or(Value::Null)
|
||||
};
|
||||
let delta = compute_field_delta(¤t, &obj);
|
||||
let set_delta = compute_field_delta(¤t, &obj);
|
||||
let clear_fields = compute_clear_fields(¤t, &to_clear);
|
||||
|
||||
if delta.is_empty() {
|
||||
// No-op edit: no entry al log, no apply. Limpia
|
||||
// editing en el caller via toast diferente.
|
||||
if set_delta.is_empty() && clear_fields.is_empty() {
|
||||
// No-op edit: no entry al log, no apply.
|
||||
return Ok(CommitOutcome::NoChange(id));
|
||||
}
|
||||
|
||||
let ops: Vec<FieldOp> = delta
|
||||
let mut ops: Vec<FieldOp> = set_delta
|
||||
.iter()
|
||||
.map(|(field, value)| FieldOp::Set {
|
||||
path: FieldPath {
|
||||
@@ -760,21 +760,40 @@ impl MetaUi {
|
||||
value: value.clone(),
|
||||
})
|
||||
.collect();
|
||||
for field in &clear_fields {
|
||||
ops.push(FieldOp::Clear {
|
||||
path: FieldPath {
|
||||
entity: entity.to_string(),
|
||||
id,
|
||||
field: field.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
let mut params = serde_json::Map::new();
|
||||
params.insert("entity".into(), json!(entity));
|
||||
params.insert("id".into(), json!(id.to_string()));
|
||||
if !set_delta.is_empty() {
|
||||
params.insert("fields".into(), Value::Object(set_delta.clone()));
|
||||
}
|
||||
if !clear_fields.is_empty() {
|
||||
params.insert(
|
||||
"cleared".into(),
|
||||
Value::Array(
|
||||
clear_fields.iter().map(|s| json!(s)).collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
log.append(LogEntry::Morphism {
|
||||
seq,
|
||||
morphism: "ui.edit_record".into(),
|
||||
inputs: Default::default(),
|
||||
params: json!({
|
||||
"entity": entity,
|
||||
"id": id.to_string(),
|
||||
"fields": Value::Object(delta.clone()),
|
||||
}),
|
||||
params: Value::Object(params),
|
||||
ops: ops.clone(),
|
||||
schema_hash: None,
|
||||
})
|
||||
@@ -784,10 +803,10 @@ impl MetaUi {
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "store mutex envenenado".to_string())?;
|
||||
store.apply(&ops).map_err(|e| format!("apply Set: {e}"))?;
|
||||
store.apply(&ops).map_err(|e| format!("apply edit ops: {e}"))?;
|
||||
Ok(CommitOutcome::Updated {
|
||||
id,
|
||||
changed: delta.len(),
|
||||
changed: set_delta.len() + clear_fields.len(),
|
||||
})
|
||||
} else {
|
||||
// SEED path: alta nueva.
|
||||
@@ -922,6 +941,24 @@ fn maybe_compact_log(
|
||||
)))
|
||||
}
|
||||
|
||||
/// Decide cuáles fields del `to_clear` candidate list ameritan
|
||||
/// realmente un `FieldOp::Clear`: sólo los que existen en el current
|
||||
/// con un valor non-null. Para fields ausentes o ya null, Clear es
|
||||
/// no-op semántico (el post-state es el mismo) y dropearlos
|
||||
/// preserva la propiedad "1 op = 1 cambio efectivo" del log.
|
||||
///
|
||||
/// Preserva el orden del input para que el log entry sea estable.
|
||||
fn compute_clear_fields(current: &Value, to_clear: &[String]) -> Vec<String> {
|
||||
to_clear
|
||||
.iter()
|
||||
.filter(|f| match current.get(f.as_str()) {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(_) => true,
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Calcula el delta entre el record actual y los valores propuestos
|
||||
/// del form. Devuelve un Map con sólo los campos cuyo valor difiere.
|
||||
///
|
||||
@@ -2022,6 +2059,44 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&snap_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_fields_skips_absent_and_null() {
|
||||
let current = json!({
|
||||
"name": "Acme",
|
||||
"notes": "lorem",
|
||||
"tag": null,
|
||||
});
|
||||
let to_clear = vec![
|
||||
"name".to_string(),
|
||||
"notes".to_string(),
|
||||
"tag".to_string(),
|
||||
"missing".to_string(),
|
||||
];
|
||||
let actual = compute_clear_fields(¤t, &to_clear);
|
||||
assert_eq!(
|
||||
actual,
|
||||
vec!["name".to_string(), "notes".to_string()],
|
||||
"tag (null) y missing (ausente) deberían filtrarse — Clear sería no-op"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_fields_preserves_input_order() {
|
||||
let current = json!({"a": 1, "b": 2, "c": 3});
|
||||
let to_clear = vec!["c".to_string(), "a".to_string(), "b".to_string()];
|
||||
let actual = compute_clear_fields(¤t, &to_clear);
|
||||
assert_eq!(actual, vec!["c", "a", "b"], "orden del input se preserva");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_fields_empty_when_current_is_null() {
|
||||
// Record no existe en el store (load → None → Value::Null
|
||||
// upstream). Ningún clear debería emitirse.
|
||||
let current = Value::Null;
|
||||
let to_clear = vec!["name".to_string()];
|
||||
assert!(compute_clear_fields(¤t, &to_clear).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_path_for_replaces_extension() {
|
||||
use std::path::Path;
|
||||
|
||||
Reference in New Issue
Block a user