feat(nakui-ui): FieldKind::EntityRef — selector clickable de records existentes
Cierra el principal trade-off documentado del commit anterior:
"Inputs UUID a mano (no dropdown)". Los formularios pueden declarar
un campo entity_ref que apunta a una entity y el runtime renderea
una lista clickable de records existentes; click selecciona, el UUID
queda guardado para el submit.
Schema:
- Nueva variante FieldKind::EntityRef (serializa como "entity_ref").
- FieldSpec.ref_entity: Option<String> nuevo. validate() chequea que
cualquier field con kind=entity_ref tenga ref_entity set.
- Nuevo SchemaError::EntityRefMissingTarget.
Runtime:
- render_entity_ref_selector helper: lista clickable debajo del input,
cada item con etiqueta humana (heuristica: name > label > title >
sku > sku_id > UUID corto) y click handler via cx.listener que
setea el TextInput con el UUID completo. Highlight en accent color
para el seleccionado.
- parse_field_value(EntityRef) devuelve string raw — validacion como
Uuid es responsabilidad de commit_morphism downstream.
- Mensaje "(sin {entity}: crea uno antes...)" cuando lista vacia —
el user sabe que hacer.
Demo actualizado sales_engine: vender_form.stock_id_input y
caja_id_input cambian a kind=entity_ref. Flujo nuevo: click en Stock
listado bajo input, click en Caja, escribir venta_id/cantidad/precio/
timestamp, submit. Sin copiar UUIDs.
Tests: 2 nuevos schema (validate detecta EntityRef sin ref_entity y
acepta con ref_entity) + 4 nuevos runtime (parse, human_label cubre
todos los key fallbacks). 29 tests totales (16 + 8 + 5).
Pendientes: confirmacion de delete, snapshot/compaction del log,
edit delta-only, validacion estricta de params del morphism via
FieldKind del FieldSpec en lugar de infer_param_value.
This commit is contained in:
@@ -6,6 +6,69 @@ ratio/diff ver `git show <sha>`.
|
|||||||
|
|
||||||
## 2026-05-09
|
## 2026-05-09
|
||||||
|
|
||||||
|
### feat(nakui-ui): FieldKind::EntityRef — selector clickable de records existentes
|
||||||
|
Cierra el principal trade-off documentado del commit anterior:
|
||||||
|
"Inputs UUID a mano (no dropdown)". Los formularios pueden declarar
|
||||||
|
un campo `entity_ref` que apunta a una entity y el runtime renderea
|
||||||
|
una lista clickable de records existentes; click selecciona, el
|
||||||
|
UUID queda guardado para el submit.
|
||||||
|
|
||||||
|
Schema `nakui-ui-schema`:
|
||||||
|
- **Nueva variante `FieldKind::EntityRef`** (serializa como
|
||||||
|
`"entity_ref"` en JSON).
|
||||||
|
- **`FieldSpec.ref_entity: Option<String>`** nuevo. Indica qué
|
||||||
|
entity ofrecer en el selector. `validate()` chequea que cualquier
|
||||||
|
field con `kind=entity_ref` tenga `ref_entity` set.
|
||||||
|
- Nuevo error tipado `SchemaError::EntityRefMissingTarget`.
|
||||||
|
|
||||||
|
Runtime `nakui-ui`:
|
||||||
|
- **`render_entity_ref_selector(field_name, target_entity, ...)`** —
|
||||||
|
helper que arma la lista debajo del input. Cada item:
|
||||||
|
- Etiqueta humana via `human_label_for_record` (heurística:
|
||||||
|
`name` → `label` → `title` → `sku` → `sku_id` → fallback al
|
||||||
|
UUID corto).
|
||||||
|
- Click handler vía `cx.listener` que llama
|
||||||
|
`input.set_text(uuid_completo)` — el TextInput interno queda
|
||||||
|
como source-of-truth, así que `commit_seed` y `commit_morphism`
|
||||||
|
leen el UUID seleccionado sin saber que vino de un selector.
|
||||||
|
- Highlight en accent color cuando el item es el actualmente
|
||||||
|
seleccionado (compara contra el contenido del TextInput).
|
||||||
|
- **`parse_field_value(EntityRef, raw)`** devuelve string del raw
|
||||||
|
(la validación como Uuid ocurre downstream en `commit_morphism`).
|
||||||
|
- Mensaje "(sin {entity}: creá uno antes para referenciar)" cuando
|
||||||
|
la lista está vacía — el user sabe qué hacer en lugar de quedarse
|
||||||
|
trabado.
|
||||||
|
|
||||||
|
Demo actualizado: `examples/nakui-modules/sales_engine/module.json`:
|
||||||
|
- `vender_form.fields.stock_id_input` y `caja_id_input` cambian de
|
||||||
|
`kind: "text"` a `kind: "entity_ref"` con `ref_entity: "Stock"`
|
||||||
|
y `"Caja"` respectivamente.
|
||||||
|
- Ahora el flujo "Vender" es: (1) click en una Stock listada bajo
|
||||||
|
el input, (2) click en una Caja, (3) escribir venta_id/cantidad/
|
||||||
|
precio_unitario/timestamp, (4) submit. Sin copiar UUIDs.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- 2 nuevos en schema: `validate_catches_entity_ref_without_target`
|
||||||
|
y `entity_ref_with_target_validates_clean`. 8 totales.
|
||||||
|
- 4 nuevos en runtime: `parse_field_entity_ref_returns_string`,
|
||||||
|
`human_label_for_record_prefers_name_over_id`,
|
||||||
|
`human_label_falls_back_through_label_title_sku`,
|
||||||
|
`human_label_falls_back_to_id_when_no_known_keys`. 16 totales.
|
||||||
|
- Integration de los 7 demos sigue verde — el demo `sales_engine`
|
||||||
|
ahora valida con EntityRef + ref_entity correctamente set.
|
||||||
|
|
||||||
|
29 tests totales nakui-ui + schema, 100% verde. El demo
|
||||||
|
`sales_engine` carga limpio con la nueva forma del schema.
|
||||||
|
|
||||||
|
Pendientes futuros:
|
||||||
|
- **Confirmación de delete** — modal antes de borrar.
|
||||||
|
- **Snapshot/compaction** del log para repos grandes.
|
||||||
|
- **Edit delta-only** (sólo campos modificados).
|
||||||
|
- **Validación de tipos en params del morphism**: `FieldKind`
|
||||||
|
declarado en el FieldSpec se podría usar para forzar parseo
|
||||||
|
estricto en `commit_morphism` en lugar de la heurística
|
||||||
|
`infer_param_value`.
|
||||||
|
|
||||||
### feat(nakui-ui): Action::Morphism wired al pipeline real (compute → log → apply)
|
### feat(nakui-ui): Action::Morphism wired al pipeline real (compute → log → apply)
|
||||||
Cierra el último gran TODO de la metainterfaz Nakui: las acciones
|
Cierra el último gran TODO de la metainterfaz Nakui: las acciones
|
||||||
`Action::Morphism` ya no son un toast informativo; despachan al
|
`Action::Morphism` ya no son un toast informativo; despachan al
|
||||||
|
|||||||
@@ -631,6 +631,10 @@ impl MetaUi {
|
|||||||
fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
|
fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
|
||||||
match kind {
|
match kind {
|
||||||
FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
|
FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
|
||||||
|
// EntityRef se almacena como string del UUID seleccionado.
|
||||||
|
// El commit_morphism luego lo parsea como Uuid para inputs;
|
||||||
|
// en seed_entity normal queda como string en el record.
|
||||||
|
FieldKind::EntityRef => Ok(json!(raw)),
|
||||||
FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
|
FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
|
||||||
"true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
|
"true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
|
||||||
"" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
|
"" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
|
||||||
@@ -648,6 +652,20 @@ fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Etiqueta humana para representar un record en el selector de
|
||||||
|
/// EntityRef. Heurística: prefiere campos comunes en este orden:
|
||||||
|
/// `name`, `label`, `title`, `sku`, `sku_id`. Fallback al UUID corto.
|
||||||
|
fn human_label_for_record(value: &Value, id: &Uuid) -> String {
|
||||||
|
for key in ["name", "label", "title", "sku", "sku_id"] {
|
||||||
|
if let Some(v) = value.get(key).and_then(Value::as_str) {
|
||||||
|
if !v.is_empty() {
|
||||||
|
return format!("{} ({})", v, short_uuid(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
short_uuid(id)
|
||||||
|
}
|
||||||
|
|
||||||
fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
|
fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
|
||||||
let mut cur = v;
|
let mut cur = v;
|
||||||
for seg in path.split('.') {
|
for seg in path.split('.') {
|
||||||
@@ -1078,6 +1096,87 @@ impl MetaUi {
|
|||||||
main
|
main
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Renderea el selector clickable de records existentes para un
|
||||||
|
/// FieldSpec con kind=EntityRef. Lista compacta debajo del input;
|
||||||
|
/// click en una opción setea el TextInput del field con el UUID
|
||||||
|
/// seleccionado. El item del UUID actualmente seleccionado (si
|
||||||
|
/// hay) se resalta con accent color.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn render_entity_ref_selector(
|
||||||
|
&self,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
field_name: String,
|
||||||
|
target_entity: String,
|
||||||
|
text: gpui::Rgba,
|
||||||
|
text_dim: gpui::Rgba,
|
||||||
|
accent: gpui::Rgba,
|
||||||
|
) -> gpui::Div {
|
||||||
|
let _ = text;
|
||||||
|
let rows = self.list_rows(&target_entity);
|
||||||
|
let current = self
|
||||||
|
.form_inputs
|
||||||
|
.get(&field_name)
|
||||||
|
.map(|inp| inp.read(&*cx).text().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut container = div()
|
||||||
|
.mt(px(4.))
|
||||||
|
.pl(px(8.))
|
||||||
|
.border_l_2()
|
||||||
|
.border_color(gpui::rgb(0x2a2f38))
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap(px(2.));
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
return container.child(
|
||||||
|
div()
|
||||||
|
.px(px(6.))
|
||||||
|
.py(px(4.))
|
||||||
|
.text_color(text_dim)
|
||||||
|
.text_size(px(10.))
|
||||||
|
.child(format!(
|
||||||
|
"(sin {target_entity}: creá uno antes para referenciar)"
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
container = container.child(
|
||||||
|
div()
|
||||||
|
.text_color(text_dim)
|
||||||
|
.text_size(px(10.))
|
||||||
|
.child(format!("Seleccioná un {target_entity}:")),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (id, value) in &rows {
|
||||||
|
let label = human_label_for_record(value, id);
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let is_selected = current == id_str;
|
||||||
|
let field_for_click = field_name.clone();
|
||||||
|
let id_for_click = id_str.clone();
|
||||||
|
container = container.child(
|
||||||
|
div()
|
||||||
|
.id(SharedString::from(format!(
|
||||||
|
"entity-ref-{field_name}-{id_str}"
|
||||||
|
)))
|
||||||
|
.px(px(6.))
|
||||||
|
.py(px(2.))
|
||||||
|
.text_size(px(11.))
|
||||||
|
.text_color(if is_selected { accent } else { text_dim })
|
||||||
|
.when(is_selected, |d| d.bg(gpui::rgb(0x232a36)))
|
||||||
|
.hover(|d| d.bg(gpui::rgb(0x1f2630)))
|
||||||
|
.child(label)
|
||||||
|
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
||||||
|
if let Some(input) = this.form_inputs.get(&field_for_click) {
|
||||||
|
input.update(cx, |inp, cx| inp.set_text(id_for_click.clone(), cx));
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
container
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn render_form(
|
fn render_form(
|
||||||
&self,
|
&self,
|
||||||
@@ -1136,6 +1235,23 @@ impl MetaUi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Para EntityRef, agregamos un selector clickable de
|
||||||
|
// records existentes debajo del TextInput. Click en una
|
||||||
|
// opción setea el TextInput interno con el UUID; el
|
||||||
|
// submit lee de ahí como cualquier otro field.
|
||||||
|
if f.kind == FieldKind::EntityRef {
|
||||||
|
if let Some(target_entity) = &f.ref_entity {
|
||||||
|
field_box = field_box.child(self.render_entity_ref_selector(
|
||||||
|
cx,
|
||||||
|
f.name.clone(),
|
||||||
|
target_entity.clone(),
|
||||||
|
text,
|
||||||
|
text_dim,
|
||||||
|
accent,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(help) = &f.help {
|
if let Some(help) = &f.help {
|
||||||
field_box = field_box.child(
|
field_box = field_box.child(
|
||||||
div()
|
div()
|
||||||
@@ -1249,6 +1365,44 @@ mod tests {
|
|||||||
assert_eq!(infer_param_value("hola"), json!("hola"));
|
assert_eq!(infer_param_value("hola"), json!("hola"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_field_entity_ref_returns_string() {
|
||||||
|
// EntityRef se almacena como string del UUID. parse_field_value
|
||||||
|
// no lo valida como UUID — eso lo hace commit_morphism al
|
||||||
|
// resolver inputs.
|
||||||
|
let v = parse_field_value(FieldKind::EntityRef, "abc-123").unwrap();
|
||||||
|
assert_eq!(v, json!("abc-123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn human_label_for_record_prefers_name_over_id() {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let with_name = json!({"name": "Acme S.A.", "email": "x@y.z"});
|
||||||
|
let label = human_label_for_record(&with_name, &id);
|
||||||
|
assert!(label.starts_with("Acme S.A."), "got: {label}");
|
||||||
|
assert!(label.contains(&short_uuid(&id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn human_label_falls_back_through_label_title_sku() {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let only_label = json!({"label": "X"});
|
||||||
|
assert!(human_label_for_record(&only_label, &id).starts_with("X "));
|
||||||
|
let only_title = json!({"title": "Y"});
|
||||||
|
assert!(human_label_for_record(&only_title, &id).starts_with("Y "));
|
||||||
|
let only_sku = json!({"sku": "Z-001"});
|
||||||
|
assert!(human_label_for_record(&only_sku, &id).starts_with("Z-001 "));
|
||||||
|
let only_sku_id = json!({"sku_id": "W-002"});
|
||||||
|
assert!(human_label_for_record(&only_sku_id, &id).starts_with("W-002 "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn human_label_falls_back_to_id_when_no_known_keys() {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let v = json!({"weird_field": "val"});
|
||||||
|
assert_eq!(human_label_for_record(&v, &id), short_uuid(&id));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_value_handles_null_string_bool() {
|
fn render_value_handles_null_string_bool() {
|
||||||
assert_eq!(render_value(None), "");
|
assert_eq!(render_value(None), "");
|
||||||
|
|||||||
@@ -176,6 +176,12 @@ pub struct FieldSpec {
|
|||||||
/// Texto de ayuda mostrado bajo el input.
|
/// Texto de ayuda mostrado bajo el input.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub help: Option<String>,
|
pub help: Option<String>,
|
||||||
|
/// Si `kind == EntityRef`, indica qué entity referencia. Sin
|
||||||
|
/// esto, el runtime no sabe qué records ofrecer en el selector
|
||||||
|
/// y la validación `Module::validate` rechaza el manifest.
|
||||||
|
/// Para los demás kinds, este campo se ignora.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ref_entity: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -191,6 +197,11 @@ pub enum FieldKind {
|
|||||||
Boolean,
|
Boolean,
|
||||||
/// Fecha (formato ISO YYYY-MM-DD; almacenada como string).
|
/// Fecha (formato ISO YYYY-MM-DD; almacenada como string).
|
||||||
Date,
|
Date,
|
||||||
|
/// Referencia a otro record. El runtime renderiza un selector
|
||||||
|
/// clickable de records existentes de la entity declarada en
|
||||||
|
/// `FieldSpec.ref_entity`; el value almacenado es el UUID del
|
||||||
|
/// seleccionado, parseable como cualquier text/UUID al submit.
|
||||||
|
EntityRef,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acciones disparables por menús, botones o submit de formularios.
|
/// Acciones disparables por menús, botones o submit de formularios.
|
||||||
@@ -279,6 +290,15 @@ pub enum SchemaError {
|
|||||||
first: PathBuf,
|
first: PathBuf,
|
||||||
second: PathBuf,
|
second: PathBuf,
|
||||||
},
|
},
|
||||||
|
#[error(
|
||||||
|
"módulo {id} vista '{view}': field '{field}' tiene kind=entity_ref \
|
||||||
|
pero no declaró ref_entity"
|
||||||
|
)]
|
||||||
|
EntityRefMissingTarget {
|
||||||
|
id: String,
|
||||||
|
view: String,
|
||||||
|
field: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Module {
|
impl Module {
|
||||||
@@ -297,8 +317,10 @@ impl Module {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validación post-parse: cada `MenuItem.view` debe existir en
|
/// Validación post-parse:
|
||||||
/// `views`. Retorna el primer error encontrado.
|
/// - Cada `MenuItem.view` debe existir en `views`.
|
||||||
|
/// - Cada `FieldSpec` con `kind=EntityRef` debe declarar
|
||||||
|
/// `ref_entity`.
|
||||||
pub fn validate(&self) -> Result<(), SchemaError> {
|
pub fn validate(&self) -> Result<(), SchemaError> {
|
||||||
for item in &self.menu {
|
for item in &self.menu {
|
||||||
if !self.views.contains_key(&item.view) {
|
if !self.views.contains_key(&item.view) {
|
||||||
@@ -309,6 +331,19 @@ impl Module {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (view_key, view) in &self.views {
|
||||||
|
if let View::Form(form) = view {
|
||||||
|
for f in &form.fields {
|
||||||
|
if f.kind == FieldKind::EntityRef && f.ref_entity.is_none() {
|
||||||
|
return Err(SchemaError::EntityRefMissingTarget {
|
||||||
|
id: self.id.clone(),
|
||||||
|
view: view_key.clone(),
|
||||||
|
field: f.name.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,6 +410,7 @@ mod tests {
|
|||||||
default: None,
|
default: None,
|
||||||
required: true,
|
required: true,
|
||||||
help: None,
|
help: None,
|
||||||
|
ref_entity: None,
|
||||||
},
|
},
|
||||||
FieldSpec {
|
FieldSpec {
|
||||||
name: "email".into(),
|
name: "email".into(),
|
||||||
@@ -383,6 +419,7 @@ mod tests {
|
|||||||
default: None,
|
default: None,
|
||||||
required: false,
|
required: false,
|
||||||
help: Some("Opcional".into()),
|
help: Some("Opcional".into()),
|
||||||
|
ref_entity: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
@@ -435,6 +472,7 @@ mod tests {
|
|||||||
default: None,
|
default: None,
|
||||||
required: true,
|
required: true,
|
||||||
help: None,
|
help: None,
|
||||||
|
ref_entity: None,
|
||||||
}],
|
}],
|
||||||
on_submit: Action::SeedEntity {
|
on_submit: Action::SeedEntity {
|
||||||
entity: "customer".into(),
|
entity: "customer".into(),
|
||||||
@@ -510,6 +548,68 @@ mod tests {
|
|||||||
assert_eq!(mods[1].id, "products");
|
assert_eq!(mods[1].id, "products");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_catches_entity_ref_without_target() {
|
||||||
|
let mut m = sample_module();
|
||||||
|
// Inyectamos un form con un campo EntityRef sin ref_entity.
|
||||||
|
m.views.insert(
|
||||||
|
"broken_form".into(),
|
||||||
|
View::Form(FormView {
|
||||||
|
title: "Roto".into(),
|
||||||
|
entity: "customer".into(),
|
||||||
|
fields: vec![FieldSpec {
|
||||||
|
name: "ref_to_nowhere".into(),
|
||||||
|
label: "Referencia".into(),
|
||||||
|
kind: FieldKind::EntityRef,
|
||||||
|
default: None,
|
||||||
|
required: true,
|
||||||
|
help: None,
|
||||||
|
ref_entity: None,
|
||||||
|
}],
|
||||||
|
on_submit: Action::SeedEntity {
|
||||||
|
entity: "customer".into(),
|
||||||
|
next_view: None,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let err = m.validate().unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, SchemaError::EntityRefMissingTarget { .. }),
|
||||||
|
"got: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entity_ref_with_target_validates_clean() {
|
||||||
|
let mut m = sample_module();
|
||||||
|
m.views.insert(
|
||||||
|
"ok_form".into(),
|
||||||
|
View::Form(FormView {
|
||||||
|
title: "OK".into(),
|
||||||
|
entity: "customer".into(),
|
||||||
|
fields: vec![FieldSpec {
|
||||||
|
name: "supplier".into(),
|
||||||
|
label: "Proveedor".into(),
|
||||||
|
kind: FieldKind::EntityRef,
|
||||||
|
default: None,
|
||||||
|
required: true,
|
||||||
|
help: None,
|
||||||
|
ref_entity: Some("supplier".into()),
|
||||||
|
}],
|
||||||
|
on_submit: Action::SeedEntity {
|
||||||
|
entity: "customer".into(),
|
||||||
|
next_view: None,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
m.menu.push(MenuItem {
|
||||||
|
label: "OK".into(),
|
||||||
|
view: "ok_form".into(),
|
||||||
|
icon: None,
|
||||||
|
});
|
||||||
|
m.validate().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_modules_detects_duplicate_id() {
|
fn load_modules_detects_duplicate_id() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -127,8 +127,8 @@
|
|||||||
"title": "Vender (morphism)",
|
"title": "Vender (morphism)",
|
||||||
"entity": "Venta",
|
"entity": "Venta",
|
||||||
"fields": [
|
"fields": [
|
||||||
{ "name": "stock_id_input", "label": "Stock UUID", "kind": "text", "required": true, "help": "Copiá el UUID corto de la lista de Stock — el runtime lo parsea como Uuid completo si es válido." },
|
{ "name": "stock_id_input", "label": "Stock", "kind": "entity_ref", "ref_entity": "Stock", "required": true, "help": "Click en un Stock de la lista para seleccionar." },
|
||||||
{ "name": "caja_id_input", "label": "Caja UUID", "kind": "text", "required": true, "help": "Idem para Caja." },
|
{ "name": "caja_id_input", "label": "Caja", "kind": "entity_ref", "ref_entity": "Caja", "required": true, "help": "Click en una Caja de la lista para seleccionar." },
|
||||||
{ "name": "venta_id", "label": "Venta UUID (idempotencia)", "kind": "text", "required": true, "help": "UUID nuevo por cada intento; mismo UUID = idempotente." },
|
{ "name": "venta_id", "label": "Venta UUID (idempotencia)", "kind": "text", "required": true, "help": "UUID nuevo por cada intento; mismo UUID = idempotente." },
|
||||||
{ "name": "cantidad", "label": "Cantidad a vender", "kind": "number", "required": true, "default": "1" },
|
{ "name": "cantidad", "label": "Cantidad a vender", "kind": "number", "required": true, "default": "1" },
|
||||||
{ "name": "precio_unitario", "label": "Precio unitario", "kind": "number", "required": true, "default": "0" },
|
{ "name": "precio_unitario", "label": "Precio unitario", "kind": "number", "required": true, "default": "0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user