feat(nakui): Fase 1 del ERP — FieldKind Select + AutoId, seed inyecta id
Primera fase del plan maestro. La metainterfaz gana dos tipos de campo: Select (chips de un conjunto cerrado, con options validadas) y AutoId (UUID autogenerado read-only). NakuiBackend::seed inyecta el id de la entity = clave del store. El módulo CRM los adopta: etapa/canal son selects, los ids de idempotencia se autogeneran, el form de cliente ya no pide id. Ningún formulario pide un UUID a mano. Tests en meta-schema, meta-runtime y nakui-ui verdes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -282,6 +282,11 @@ impl MetaBackend for NakuiBackend {
|
|||||||
data: serde_json::Map<String, Value>,
|
data: serde_json::Map<String, Value>,
|
||||||
) -> Result<WriteOutcome, String> {
|
) -> Result<WriteOutcome, String> {
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
|
// El `id` de la entity = la clave del store. Inyectarlo en el
|
||||||
|
// record hace que `data.id` y la clave coincidan — los schemas
|
||||||
|
// Nickel suelen declarar `id | String` y los morfismos lo leen.
|
||||||
|
let mut data = data;
|
||||||
|
data.insert("id".to_string(), Value::String(id.to_string()));
|
||||||
let value = Value::Object(data);
|
let value = Value::Object(data);
|
||||||
// WAL: log primero, store después.
|
// WAL: log primero, store después.
|
||||||
if self.event_log.is_some() {
|
if self.event_log.is_some() {
|
||||||
|
|||||||
@@ -137,7 +137,10 @@ mod tests {
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
|
fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
|
||||||
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.clone()))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -180,7 +183,10 @@ mod tests {
|
|||||||
fn update_with_set_changes_field() {
|
fn update_with_set_changes_field() {
|
||||||
let mut b = MockBackend::new();
|
let mut b = MockBackend::new();
|
||||||
let id = b
|
let id = b
|
||||||
.seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))]))
|
.seed(
|
||||||
|
"Customer",
|
||||||
|
map_of(&[("name", json!("Acme")), ("notes", json!("x"))]),
|
||||||
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.id
|
.id
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -205,7 +211,10 @@ mod tests {
|
|||||||
fn update_with_clear_removes_key() {
|
fn update_with_clear_removes_key() {
|
||||||
let mut b = MockBackend::new();
|
let mut b = MockBackend::new();
|
||||||
let id = b
|
let id = b
|
||||||
.seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))]))
|
.seed(
|
||||||
|
"Customer",
|
||||||
|
map_of(&[("name", json!("Acme")), ("notes", json!("x"))]),
|
||||||
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.id
|
.id
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ mod tests {
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn map(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
|
fn map(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
|
||||||
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.clone()))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -15,15 +15,20 @@ use nahual_meta_schema::{FieldKind, FieldSpec};
|
|||||||
/// - `Number` → i64 si parsea, sino f64.
|
/// - `Number` → i64 si parsea, sino f64.
|
||||||
pub fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
|
pub fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
|
||||||
match kind {
|
match kind {
|
||||||
FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
|
// Select y AutoId guardan un string: el valor de la opción
|
||||||
|
// elegida y el UUID autogenerado, respectivamente.
|
||||||
|
FieldKind::Text
|
||||||
|
| FieldKind::Multiline
|
||||||
|
| FieldKind::Date
|
||||||
|
| FieldKind::Select
|
||||||
|
| FieldKind::AutoId => Ok(json!(raw)),
|
||||||
// EntityRef se almacena como string del UUID seleccionado.
|
// EntityRef se almacena como string del UUID seleccionado.
|
||||||
// El selector clickable garantiza UUIDs válidos en happy
|
// El selector clickable garantiza UUIDs válidos en happy
|
||||||
// path; este check protege paste manual o garbage tipeado.
|
// path; este check protege paste manual o garbage tipeado.
|
||||||
FieldKind::EntityRef => {
|
FieldKind::EntityRef => {
|
||||||
let trimmed = raw.trim();
|
let trimmed = raw.trim();
|
||||||
Uuid::parse_str(trimmed).map_err(|_| {
|
Uuid::parse_str(trimmed)
|
||||||
format!("'{raw}' no es UUID válido (usá el selector de records)")
|
.map_err(|_| format!("'{raw}' no es UUID válido (usá el selector de records)"))?;
|
||||||
})?;
|
|
||||||
Ok(json!(trimmed))
|
Ok(json!(trimmed))
|
||||||
}
|
}
|
||||||
FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
|
FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
|
||||||
@@ -62,7 +67,11 @@ pub fn resolve_param_value(
|
|||||||
return Ok(infer_param_value(raw));
|
return Ok(infer_param_value(raw));
|
||||||
};
|
};
|
||||||
|
|
||||||
let label = if s.label.is_empty() { field_name } else { &s.label };
|
let label = if s.label.is_empty() {
|
||||||
|
field_name
|
||||||
|
} else {
|
||||||
|
&s.label
|
||||||
|
};
|
||||||
|
|
||||||
if s.required && raw.trim().is_empty() {
|
if s.required && raw.trim().is_empty() {
|
||||||
return Err(format!("param '{label}' es obligatorio y está vacío"));
|
return Err(format!("param '{label}' es obligatorio y está vacío"));
|
||||||
@@ -112,6 +121,7 @@ mod tests {
|
|||||||
required,
|
required,
|
||||||
help: None,
|
help: None,
|
||||||
ref_entity: None,
|
ref_entity: None,
|
||||||
|
options: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +129,7 @@ mod tests {
|
|||||||
fn infer_handles_basic_types() {
|
fn infer_handles_basic_types() {
|
||||||
assert_eq!(infer_param_value(""), Value::Null);
|
assert_eq!(infer_param_value(""), Value::Null);
|
||||||
assert_eq!(infer_param_value("42"), json!(42));
|
assert_eq!(infer_param_value("42"), json!(42));
|
||||||
assert_eq!(infer_param_value("3.14"), json!(3.14));
|
assert_eq!(infer_param_value("2.5"), json!(2.5));
|
||||||
assert_eq!(infer_param_value("true"), json!(true));
|
assert_eq!(infer_param_value("true"), json!(true));
|
||||||
assert_eq!(infer_param_value("false"), json!(false));
|
assert_eq!(infer_param_value("false"), json!(false));
|
||||||
assert_eq!(infer_param_value("hola"), json!("hola"));
|
assert_eq!(infer_param_value("hola"), json!("hola"));
|
||||||
@@ -132,11 +142,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_number_i64_or_f64() {
|
fn parse_select_and_auto_id_passthrough() {
|
||||||
assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42));
|
// Select guarda el valor de la opción elegida.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_field_value(FieldKind::Number, "3.14").unwrap(),
|
parse_field_value(FieldKind::Select, "ganada").unwrap(),
|
||||||
json!(3.14)
|
json!("ganada")
|
||||||
|
);
|
||||||
|
// AutoId guarda el UUID autogenerado tal cual.
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
assert_eq!(
|
||||||
|
parse_field_value(FieldKind::AutoId, &id).unwrap(),
|
||||||
|
json!(id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_number_i64_or_f64() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_field_value(FieldKind::Number, "42").unwrap(),
|
||||||
|
json!(42)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_field_value(FieldKind::Number, "2.5").unwrap(),
|
||||||
|
json!(2.5)
|
||||||
);
|
);
|
||||||
assert!(parse_field_value(FieldKind::Number, "abc").is_err());
|
assert!(parse_field_value(FieldKind::Number, "abc").is_err());
|
||||||
}
|
}
|
||||||
@@ -144,7 +172,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_boolean_recognizes_variants() {
|
fn parse_boolean_recognizes_variants() {
|
||||||
for s in ["true", "yes", "1", "on", "y"] {
|
for s in ["true", "yes", "1", "on", "y"] {
|
||||||
assert_eq!(parse_field_value(FieldKind::Boolean, s).unwrap(), json!(true));
|
assert_eq!(
|
||||||
|
parse_field_value(FieldKind::Boolean, s).unwrap(),
|
||||||
|
json!(true)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for s in ["false", "no", "0", "off", "n", ""] {
|
for s in ["false", "no", "0", "off", "n", ""] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -204,30 +204,24 @@ mod tests {
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
|
fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
|
||||||
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.clone()))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn with_records_populates_state() {
|
fn with_records_populates_state() {
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let b = MockBackend::with_records([(
|
let b = MockBackend::with_records([("Customer".into(), id, json!({"name": "Acme"}))]);
|
||||||
"Customer".into(),
|
|
||||||
id,
|
|
||||||
json!({"name": "Acme"}),
|
|
||||||
)]);
|
|
||||||
assert_eq!(b.total_records(), 1);
|
assert_eq!(b.total_records(), 1);
|
||||||
assert_eq!(
|
assert_eq!(b.load_record("Customer", id), Some(json!({"name": "Acme"})));
|
||||||
b.load_record("Customer", id),
|
|
||||||
Some(json!({"name": "Acme"}))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn seed_then_load_round_trip_via_trait() {
|
fn seed_then_load_round_trip_via_trait() {
|
||||||
let mut b = MockBackend::new();
|
let mut b = MockBackend::new();
|
||||||
let out = b
|
let out = b.seed("X", map_of(&[("k", json!(1))])).unwrap();
|
||||||
.seed("X", map_of(&[("k", json!(1))]))
|
|
||||||
.unwrap();
|
|
||||||
let id = out.id.unwrap();
|
let id = out.id.unwrap();
|
||||||
assert_eq!(out.changed, 1);
|
assert_eq!(out.changed, 1);
|
||||||
assert_eq!(b.load_record("X", id), Some(json!({"k": 1})));
|
assert_eq!(b.load_record("X", id), Some(json!({"k": 1})));
|
||||||
@@ -236,25 +230,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn update_no_op_returns_no_change() {
|
fn update_no_op_returns_no_change() {
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let mut b = MockBackend::with_records([(
|
let mut b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
|
||||||
"X".into(),
|
let out = b.update("X", id, serde_json::Map::new(), vec![]).unwrap();
|
||||||
id,
|
|
||||||
json!({"k": 1}),
|
|
||||||
)]);
|
|
||||||
let out = b
|
|
||||||
.update("X", id, serde_json::Map::new(), vec![])
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(out, WriteOutcome::no_change(id));
|
assert_eq!(out, WriteOutcome::no_change(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn update_set_and_clear_aplica_ambos() {
|
fn update_set_and_clear_aplica_ambos() {
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let mut b = MockBackend::with_records([(
|
let mut b = MockBackend::with_records([("X".into(), id, json!({"a": 1, "b": 2}))]);
|
||||||
"X".into(),
|
|
||||||
id,
|
|
||||||
json!({"a": 1, "b": 2}),
|
|
||||||
)]);
|
|
||||||
let out = b
|
let out = b
|
||||||
.update("X", id, map_of(&[("a", json!(10))]), vec!["b".into()])
|
.update("X", id, map_of(&[("a", json!(10))]), vec!["b".into()])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -267,11 +251,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn delete_then_load_returns_none() {
|
fn delete_then_load_returns_none() {
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let mut b = MockBackend::with_records([(
|
let mut b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
|
||||||
"X".into(),
|
|
||||||
id,
|
|
||||||
json!({"k": 1}),
|
|
||||||
)]);
|
|
||||||
b.delete("X", id).unwrap();
|
b.delete("X", id).unwrap();
|
||||||
assert!(b.load_record("X", id).is_none());
|
assert!(b.load_record("X", id).is_none());
|
||||||
}
|
}
|
||||||
@@ -287,17 +267,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn with_morphism_lets_caller_simulate_logic() {
|
fn with_morphism_lets_caller_simulate_logic() {
|
||||||
let mut b = MockBackend::new().with_morphism(
|
let mut b = MockBackend::new().with_morphism("double_qty", |inputs, params| {
|
||||||
"double_qty",
|
|
||||||
|inputs, params| {
|
|
||||||
assert!(inputs.is_empty());
|
assert!(inputs.is_empty());
|
||||||
let qty = params.get("qty").and_then(|v| v.as_i64()).unwrap_or(0);
|
let qty = params.get("qty").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
if qty <= 0 {
|
if qty <= 0 {
|
||||||
return Err("qty must be positive".into());
|
return Err("qty must be positive".into());
|
||||||
}
|
}
|
||||||
Ok(qty as usize)
|
Ok(qty as usize)
|
||||||
},
|
});
|
||||||
);
|
|
||||||
let out = b
|
let out = b
|
||||||
.morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 7}))
|
.morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 7}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -326,11 +303,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn records_for_returns_borrowed_view() {
|
fn records_for_returns_borrowed_view() {
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let b = MockBackend::with_records([(
|
let b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
|
||||||
"X".into(),
|
|
||||||
id,
|
|
||||||
json!({"k": 1}),
|
|
||||||
)]);
|
|
||||||
let view = b.records_for("X");
|
let view = b.records_for("X");
|
||||||
assert_eq!(view.len(), 1);
|
assert_eq!(view.len(), 1);
|
||||||
assert_eq!(view[0].0, id);
|
assert_eq!(view[0].0, id);
|
||||||
|
|||||||
@@ -190,6 +190,10 @@ pub struct FieldSpec {
|
|||||||
/// Para los demás kinds, este campo se ignora.
|
/// Para los demás kinds, este campo se ignora.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ref_entity: Option<String>,
|
pub ref_entity: Option<String>,
|
||||||
|
/// Opciones de un campo `kind == Select`. Ignorado para los demás
|
||||||
|
/// kinds. `Module::validate` exige que un Select las tenga.
|
||||||
|
#[serde(default)]
|
||||||
|
pub options: Vec<SelectOption>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -210,6 +214,31 @@ pub enum FieldKind {
|
|||||||
/// `FieldSpec.ref_entity`; el value almacenado es el UUID del
|
/// `FieldSpec.ref_entity`; el value almacenado es el UUID del
|
||||||
/// seleccionado, parseable como cualquier text/UUID al submit.
|
/// seleccionado, parseable como cualquier text/UUID al submit.
|
||||||
EntityRef,
|
EntityRef,
|
||||||
|
/// Valor elegido de un conjunto cerrado declarado en
|
||||||
|
/// `FieldSpec.options`. El runtime lo renderiza como selección
|
||||||
|
/// (no texto libre). `Module::validate` exige `options` no vacío.
|
||||||
|
Select,
|
||||||
|
/// Identificador autogenerado (UUID v4). El runtime lo rellena al
|
||||||
|
/// abrir el formulario; el usuario no lo teclea ni lo edita. Para
|
||||||
|
/// los ids de idempotencia que piden los morfismos.
|
||||||
|
AutoId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Una opción de un campo [`FieldKind::Select`].
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SelectOption {
|
||||||
|
/// Valor que se guarda (lo que recibe el backend o el morfismo).
|
||||||
|
pub value: String,
|
||||||
|
/// Etiqueta legible. Si se omite, se muestra el `value` crudo.
|
||||||
|
#[serde(default)]
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SelectOption {
|
||||||
|
/// Texto a mostrar: `label` si está, sino el `value`.
|
||||||
|
pub fn display(&self) -> &str {
|
||||||
|
self.label.as_deref().unwrap_or(&self.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acciones disparables por menús, botones o submit de formularios.
|
/// Acciones disparables por menús, botones o submit de formularios.
|
||||||
@@ -307,6 +336,15 @@ pub enum SchemaError {
|
|||||||
view: String,
|
view: String,
|
||||||
field: String,
|
field: String,
|
||||||
},
|
},
|
||||||
|
#[error(
|
||||||
|
"módulo {id} vista '{view}': field '{field}' tiene kind=select \
|
||||||
|
pero no declaró options"
|
||||||
|
)]
|
||||||
|
SelectMissingOptions {
|
||||||
|
id: String,
|
||||||
|
view: String,
|
||||||
|
field: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Module {
|
impl Module {
|
||||||
@@ -349,6 +387,13 @@ impl Module {
|
|||||||
field: f.name.clone(),
|
field: f.name.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if f.kind == FieldKind::Select && f.options.is_empty() {
|
||||||
|
return Err(SchemaError::SelectMissingOptions {
|
||||||
|
id: self.id.clone(),
|
||||||
|
view: view_key.clone(),
|
||||||
|
field: f.name.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -419,6 +464,7 @@ mod tests {
|
|||||||
required: true,
|
required: true,
|
||||||
help: None,
|
help: None,
|
||||||
ref_entity: None,
|
ref_entity: None,
|
||||||
|
options: Vec::new(),
|
||||||
},
|
},
|
||||||
FieldSpec {
|
FieldSpec {
|
||||||
name: "email".into(),
|
name: "email".into(),
|
||||||
@@ -428,6 +474,7 @@ mod tests {
|
|||||||
required: false,
|
required: false,
|
||||||
help: Some("Opcional".into()),
|
help: Some("Opcional".into()),
|
||||||
ref_entity: None,
|
ref_entity: None,
|
||||||
|
options: Vec::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
@@ -481,6 +528,7 @@ mod tests {
|
|||||||
required: true,
|
required: true,
|
||||||
help: None,
|
help: None,
|
||||||
ref_entity: None,
|
ref_entity: None,
|
||||||
|
options: Vec::new(),
|
||||||
}],
|
}],
|
||||||
on_submit: Action::SeedEntity {
|
on_submit: Action::SeedEntity {
|
||||||
entity: "customer".into(),
|
entity: "customer".into(),
|
||||||
@@ -573,6 +621,7 @@ mod tests {
|
|||||||
required: true,
|
required: true,
|
||||||
help: None,
|
help: None,
|
||||||
ref_entity: None,
|
ref_entity: None,
|
||||||
|
options: Vec::new(),
|
||||||
}],
|
}],
|
||||||
on_submit: Action::SeedEntity {
|
on_submit: Action::SeedEntity {
|
||||||
entity: "customer".into(),
|
entity: "customer".into(),
|
||||||
@@ -603,6 +652,7 @@ mod tests {
|
|||||||
required: true,
|
required: true,
|
||||||
help: None,
|
help: None,
|
||||||
ref_entity: Some("supplier".into()),
|
ref_entity: Some("supplier".into()),
|
||||||
|
options: Vec::new(),
|
||||||
}],
|
}],
|
||||||
on_submit: Action::SeedEntity {
|
on_submit: Action::SeedEntity {
|
||||||
entity: "customer".into(),
|
entity: "customer".into(),
|
||||||
@@ -618,6 +668,92 @@ mod tests {
|
|||||||
m.validate().unwrap();
|
m.validate().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_without_options_is_rejected() {
|
||||||
|
let mut m = sample_module();
|
||||||
|
m.views.insert(
|
||||||
|
"sel_form".into(),
|
||||||
|
View::Form(FormView {
|
||||||
|
title: "Select roto".into(),
|
||||||
|
entity: "customer".into(),
|
||||||
|
fields: vec![FieldSpec {
|
||||||
|
name: "estado".into(),
|
||||||
|
label: "Estado".into(),
|
||||||
|
kind: FieldKind::Select,
|
||||||
|
default: None,
|
||||||
|
required: true,
|
||||||
|
help: None,
|
||||||
|
ref_entity: None,
|
||||||
|
options: Vec::new(),
|
||||||
|
}],
|
||||||
|
on_submit: Action::SeedEntity {
|
||||||
|
entity: "customer".into(),
|
||||||
|
next_view: None,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let err = m.validate().unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, SchemaError::SelectMissingOptions { .. }),
|
||||||
|
"got: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_with_options_validates_clean() {
|
||||||
|
let mut m = sample_module();
|
||||||
|
m.views.insert(
|
||||||
|
"sel_form".into(),
|
||||||
|
View::Form(FormView {
|
||||||
|
title: "Select OK".into(),
|
||||||
|
entity: "customer".into(),
|
||||||
|
fields: vec![FieldSpec {
|
||||||
|
name: "estado".into(),
|
||||||
|
label: "Estado".into(),
|
||||||
|
kind: FieldKind::Select,
|
||||||
|
default: None,
|
||||||
|
required: true,
|
||||||
|
help: None,
|
||||||
|
ref_entity: None,
|
||||||
|
options: vec![
|
||||||
|
SelectOption {
|
||||||
|
value: "activo".into(),
|
||||||
|
label: Some("Activo".into()),
|
||||||
|
},
|
||||||
|
SelectOption {
|
||||||
|
value: "baja".into(),
|
||||||
|
label: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
on_submit: Action::SeedEntity {
|
||||||
|
entity: "customer".into(),
|
||||||
|
next_view: None,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
m.menu.push(MenuItem {
|
||||||
|
label: "Sel".into(),
|
||||||
|
view: "sel_form".into(),
|
||||||
|
icon: None,
|
||||||
|
});
|
||||||
|
m.validate().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_option_display_falls_back_to_value() {
|
||||||
|
let with_label = SelectOption {
|
||||||
|
value: "x".into(),
|
||||||
|
label: Some("Equis".into()),
|
||||||
|
};
|
||||||
|
let bare = SelectOption {
|
||||||
|
value: "y".into(),
|
||||||
|
label: None,
|
||||||
|
};
|
||||||
|
assert_eq!(with_label.display(), "Equis");
|
||||||
|
assert_eq!(bare.display(), "y");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_modules_detects_duplicate_id() {
|
fn load_modules_detects_duplicate_id() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
@@ -626,16 +762,8 @@ mod tests {
|
|||||||
std::fs::create_dir_all(&a_dir).unwrap();
|
std::fs::create_dir_all(&a_dir).unwrap();
|
||||||
std::fs::create_dir_all(&b_dir).unwrap();
|
std::fs::create_dir_all(&b_dir).unwrap();
|
||||||
let m = sample_module(); // id = "customers"
|
let m = sample_module(); // id = "customers"
|
||||||
std::fs::write(
|
std::fs::write(a_dir.join("module.json"), serde_json::to_vec(&m).unwrap()).unwrap();
|
||||||
a_dir.join("module.json"),
|
std::fs::write(b_dir.join("module.json"), serde_json::to_vec(&m).unwrap()).unwrap();
|
||||||
serde_json::to_vec(&m).unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
b_dir.join("module.json"),
|
|
||||||
serde_json::to_vec(&m).unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let err = load_modules_from_dir(tmp.path()).unwrap_err();
|
let err = load_modules_from_dir(tmp.path()).unwrap_err();
|
||||||
assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
|
assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ fn loads_all_demo_modules() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
ids,
|
ids,
|
||||||
vec![
|
vec![
|
||||||
|
"crm",
|
||||||
"customers",
|
"customers",
|
||||||
"inventory_movements",
|
"inventory_movements",
|
||||||
"invoices",
|
"invoices",
|
||||||
@@ -34,8 +35,8 @@ fn loads_all_demo_modules() {
|
|||||||
"sales_orders",
|
"sales_orders",
|
||||||
"suppliers",
|
"suppliers",
|
||||||
],
|
],
|
||||||
"expected 7 modules in alphabetical order \
|
"expected 8 modules in alphabetical order \
|
||||||
(sales_engine se sumó al wirear Action::Morphism)"
|
(crm se sumó como ERP con morfismos)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,18 +26,20 @@ use gpui::{
|
|||||||
SharedString, Window,
|
SharedString, Window,
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde_json::Value;
|
|
||||||
use uuid::Uuid;
|
|
||||||
use nahual_meta_runtime::{
|
use nahual_meta_runtime::{
|
||||||
compute_clear_fields, compute_field_delta, human_label_for_record, parse_field_value,
|
compute_clear_fields, compute_field_delta, human_label_for_record, parse_field_value,
|
||||||
render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text,
|
render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text,
|
||||||
MetaBackend, WriteOutcome,
|
MetaBackend, WriteOutcome,
|
||||||
};
|
};
|
||||||
use nahual_meta_schema::{Action, FieldKind, FieldSpec, FormView, ListView, Module, View};
|
use nahual_meta_schema::{
|
||||||
|
Action, FieldKind, FieldSpec, FormView, ListView, Module, SelectOption, View,
|
||||||
|
};
|
||||||
use nahual_theme::Theme;
|
use nahual_theme::Theme;
|
||||||
use nahual_widget_banner::{banner_themed, themed_colors, Banner};
|
use nahual_widget_banner::{banner_themed, themed_colors, Banner};
|
||||||
use nahual_widget_text_input::TextInput;
|
use nahual_widget_text_input::TextInput;
|
||||||
use nahual_widget_theme_switcher::theme_switcher;
|
use nahual_widget_theme_switcher::theme_switcher;
|
||||||
|
use serde_json::Value;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Estado del runtime de UI. Toda la persistencia/ejecución está
|
/// Estado del runtime de UI. Toda la persistencia/ejecución está
|
||||||
/// detrás del trait [`MetaBackend`]; este struct sólo conoce GPUI
|
/// detrás del trait [`MetaBackend`]; este struct sólo conoce GPUI
|
||||||
@@ -125,7 +127,14 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
for f in &form.fields {
|
for f in &form.fields {
|
||||||
let initial = if let Some(rec) = &editing_record {
|
let initial = if f.kind == FieldKind::AutoId {
|
||||||
|
// Editando: conservar el id del record.
|
||||||
|
// Alta: UUID nuevo, que el usuario no teclea.
|
||||||
|
editing_record
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|rec| rec.get(&f.name).map(value_to_input_text))
|
||||||
|
.unwrap_or_else(|| Uuid::new_v4().to_string())
|
||||||
|
} else if let Some(rec) = &editing_record {
|
||||||
rec.get(&f.name)
|
rec.get(&f.name)
|
||||||
.map(value_to_input_text)
|
.map(value_to_input_text)
|
||||||
.unwrap_or_else(|| f.default.clone().unwrap_or_default())
|
.unwrap_or_else(|| f.default.clone().unwrap_or_default())
|
||||||
@@ -146,18 +155,10 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
|
|
||||||
/// Inicia un edit del record: setea `editing` y abre la primera
|
/// Inicia un edit del record: setea `editing` y abre la primera
|
||||||
/// view de tipo Form del módulo (convención: la del schema).
|
/// view de tipo Form del módulo (convención: la del schema).
|
||||||
fn open_edit(
|
fn open_edit(&mut self, mod_idx: usize, entity: String, id: Uuid, cx: &mut Context<Self>) {
|
||||||
&mut self,
|
|
||||||
mod_idx: usize,
|
|
||||||
entity: String,
|
|
||||||
id: Uuid,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
self.editing = Some((entity.clone(), id));
|
self.editing = Some((entity.clone(), id));
|
||||||
let form_view_key = self.modules.get(mod_idx).and_then(|m| {
|
let form_view_key = self.modules.get(mod_idx).and_then(|m| {
|
||||||
m.views
|
m.views.iter().find_map(|(key, v)| match v {
|
||||||
.iter()
|
|
||||||
.find_map(|(key, v)| match v {
|
|
||||||
View::Form(form) if form.entity == entity => Some(key.clone()),
|
View::Form(form) if form.entity == entity => Some(key.clone()),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
@@ -179,11 +180,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
/// el modelo de "todo cambio post-seed pasa por ops").
|
/// el modelo de "todo cambio post-seed pasa por ops").
|
||||||
/// Borra un record vía `MetaBackend::delete`. Devuelve el outcome
|
/// Borra un record vía `MetaBackend::delete`. Devuelve el outcome
|
||||||
/// del backend (incluye eventual `post_status` del compact tick).
|
/// del backend (incluye eventual `post_status` del compact tick).
|
||||||
fn commit_delete(
|
fn commit_delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String> {
|
||||||
&mut self,
|
|
||||||
entity: &str,
|
|
||||||
id: Uuid,
|
|
||||||
) -> Result<WriteOutcome, String> {
|
|
||||||
self.backend.delete(entity, id)
|
self.backend.delete(entity, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,19 +229,16 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
} => {
|
} => {
|
||||||
match self.commit_morphism(mod_idx, &name, &inputs, ¶ms, cx) {
|
match self.commit_morphism(mod_idx, &name, &inputs, ¶ms, cx) {
|
||||||
Ok(outcome) => {
|
Ok(outcome) => {
|
||||||
let base = format!(
|
let base =
|
||||||
"morphism '{name}' OK ({} op(s) aplicadas)",
|
format!("morphism '{name}' OK ({} op(s) aplicadas)", outcome.changed);
|
||||||
outcome.changed
|
|
||||||
);
|
|
||||||
self.toast = Some(append_compact_msg(base, outcome.post_status));
|
self.toast = Some(append_compact_msg(base, outcome.post_status));
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.toast = Some(SharedString::from(format!(
|
self.toast =
|
||||||
"morphism '{name}' falló: {e}"
|
Some(SharedString::from(format!("morphism '{name}' falló: {e}")));
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -278,9 +272,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
.map(|inp| inp.read(&*cx).text().to_string())
|
.map(|inp| inp.read(&*cx).text().to_string())
|
||||||
.ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
|
.ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
|
||||||
let id = Uuid::parse_str(raw.trim()).map_err(|_| {
|
let id = Uuid::parse_str(raw.trim()).map_err(|_| {
|
||||||
format!(
|
format!("input '{role}' (field '{field_name}'): '{raw}' no es UUID válido")
|
||||||
"input '{role}' (field '{field_name}'): '{raw}' no es UUID válido"
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
inputs.insert(role.clone(), id);
|
inputs.insert(role.clone(), id);
|
||||||
}
|
}
|
||||||
@@ -363,12 +355,11 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
to_clear.push(f.name.clone());
|
to_clear.push(f.name.clone());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let value = parse_field_value(f.kind, &raw)
|
let value =
|
||||||
.map_err(|e| format!("campo '{}': {e}", f.label))?;
|
parse_field_value(f.kind, &raw).map_err(|e| format!("campo '{}': {e}", f.label))?;
|
||||||
if f.kind == FieldKind::EntityRef {
|
if f.kind == FieldKind::EntityRef {
|
||||||
if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
|
if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
|
||||||
let id = Uuid::parse_str(uuid_str)
|
let id = Uuid::parse_str(uuid_str).expect("parse_field_value validated UUID");
|
||||||
.expect("parse_field_value validated UUID");
|
|
||||||
entity_refs.push((f.label.clone(), target.clone(), id));
|
entity_refs.push((f.label.clone(), target.clone(), id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,10 +400,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
/// vs "actualizado" — el WriteOutcome solo no alcanza porque
|
/// vs "actualizado" — el WriteOutcome solo no alcanza porque
|
||||||
/// `seed` y `update` ambos devuelven `id = Some(...)`.
|
/// `seed` y `update` ambos devuelven `id = Some(...)`.
|
||||||
fn format_seed_toast(entity: &str, was_editing: bool, outcome: &WriteOutcome) -> String {
|
fn format_seed_toast(entity: &str, was_editing: bool, outcome: &WriteOutcome) -> String {
|
||||||
let id_short = outcome
|
let id_short = outcome.id.map(|id| short_uuid(&id)).unwrap_or_default();
|
||||||
.id
|
|
||||||
.map(|id| short_uuid(&id))
|
|
||||||
.unwrap_or_default();
|
|
||||||
match (was_editing, outcome.changed) {
|
match (was_editing, outcome.changed) {
|
||||||
(false, _) => format!("creado {entity} {id_short}"),
|
(false, _) => format!("creado {entity} {id_short}"),
|
||||||
(true, 0) => format!("{entity} {id_short} sin cambios — no log entry"),
|
(true, 0) => format!("{entity} {id_short} sin cambios — no log entry"),
|
||||||
@@ -450,16 +438,15 @@ impl<B: MetaBackend> Render for MetaApp<B> {
|
|||||||
// Si el caller no instaló un Theme, `Theme::global` panicea.
|
// Si el caller no instaló un Theme, `Theme::global` panicea.
|
||||||
// Convención: el binario shell instala el theme en main.
|
// Convención: el binario shell instala el theme en main.
|
||||||
let theme = Theme::global(cx).clone();
|
let theme = Theme::global(cx).clone();
|
||||||
let bg = theme.bg_app.clone();
|
let bg = theme.bg_app;
|
||||||
let panel = theme.bg_panel.clone();
|
let panel = theme.bg_panel;
|
||||||
let border = theme.border;
|
let border = theme.border;
|
||||||
let text = theme.fg_text;
|
let text = theme.fg_text;
|
||||||
let text_dim = theme.fg_muted;
|
let text_dim = theme.fg_muted;
|
||||||
let accent = theme.accent;
|
let accent = theme.accent;
|
||||||
let accent_active = theme.accent_strong;
|
let accent_active = theme.accent_strong;
|
||||||
|
|
||||||
let sidebar =
|
let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
|
||||||
self.render_sidebar(cx, panel.clone(), border, text, text_dim, accent_active);
|
|
||||||
let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
|
let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
|
||||||
let confirm_banner = self.render_confirm_delete_banner(cx);
|
let confirm_banner = self.render_confirm_delete_banner(cx);
|
||||||
let toast_div = self
|
let toast_div = self
|
||||||
@@ -529,7 +516,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
let theme = Theme::global(cx);
|
let theme = Theme::global(cx);
|
||||||
let (banner_bg, banner_text) = themed_colors(Banner::Warning, theme);
|
let (banner_bg, banner_text) = themed_colors(Banner::Warning, theme);
|
||||||
let (confirm_bg, confirm_text) = themed_colors(Banner::Error, theme);
|
let (confirm_bg, confirm_text) = themed_colors(Banner::Error, theme);
|
||||||
let cancel_bg: gpui::Background = theme.bg_panel_alt.clone();
|
let cancel_bg: gpui::Background = theme.bg_panel_alt;
|
||||||
let cancel_text = theme.fg_text;
|
let cancel_text = theme.fg_text;
|
||||||
// Hover colors capturados antes de las closures para que el
|
// Hover colors capturados antes de las closures para que el
|
||||||
// move |d| d.bg(...) los cierre.
|
// move |d| d.bg(...) los cierre.
|
||||||
@@ -583,10 +570,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.id(SharedString::from(format!(
|
.id(SharedString::from(format!("confirm-del-ok-{}", id_owned)))
|
||||||
"confirm-del-ok-{}",
|
|
||||||
id_owned
|
|
||||||
)))
|
|
||||||
.px(px(10.))
|
.px(px(10.))
|
||||||
.py(px(4.))
|
.py(px(4.))
|
||||||
.bg(confirm_bg)
|
.bg(confirm_bg)
|
||||||
@@ -604,15 +588,12 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
"borrado {entity_for_confirm} {}",
|
"borrado {entity_for_confirm} {}",
|
||||||
short_uuid(&id_owned)
|
short_uuid(&id_owned)
|
||||||
);
|
);
|
||||||
this.toast = Some(append_compact_msg(
|
this.toast =
|
||||||
base,
|
Some(append_compact_msg(base, outcome.post_status));
|
||||||
outcome.post_status,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.toast = Some(SharedString::from(format!(
|
this.toast =
|
||||||
"error borrando: {e}"
|
Some(SharedString::from(format!("error borrando: {e}")));
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -761,8 +742,12 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match view {
|
match view {
|
||||||
View::List(lv) => self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent),
|
View::List(lv) => {
|
||||||
View::Form(fv) => self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent),
|
self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent)
|
||||||
|
}
|
||||||
|
View::Form(fv) => {
|
||||||
|
self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,9 +794,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
let action_clone = action.clone();
|
let action_clone = action.clone();
|
||||||
header = header.child(
|
header = header.child(
|
||||||
div()
|
div()
|
||||||
.id(SharedString::from(format!(
|
.id(SharedString::from(format!("list-action-{mod_idx}-{idx}")))
|
||||||
"list-action-{mod_idx}-{idx}"
|
|
||||||
)))
|
|
||||||
.px(px(10.))
|
.px(px(10.))
|
||||||
.py(px(4.))
|
.py(px(4.))
|
||||||
.bg(action_bg)
|
.bg(action_bg)
|
||||||
@@ -892,9 +875,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
.gap(px(4.))
|
.gap(px(4.))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.id(SharedString::from(format!(
|
.id(SharedString::from(format!("row-edit-{mod_idx}-{id_copy}")))
|
||||||
"row-edit-{mod_idx}-{id_copy}"
|
|
||||||
)))
|
|
||||||
.px(px(6.))
|
.px(px(6.))
|
||||||
.text_color(accent)
|
.text_color(accent)
|
||||||
.text_size(px(13.))
|
.text_size(px(13.))
|
||||||
@@ -906,9 +887,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.id(SharedString::from(format!(
|
.id(SharedString::from(format!("row-del-{mod_idx}-{id_copy}")))
|
||||||
"row-del-{mod_idx}-{id_copy}"
|
|
||||||
)))
|
|
||||||
.px(px(6.))
|
.px(px(6.))
|
||||||
.text_color(destructive_fg)
|
.text_color(destructive_fg)
|
||||||
.text_size(px(13.))
|
.text_size(px(13.))
|
||||||
@@ -919,8 +898,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
// directo: el modal de confirmación se
|
// directo: el modal de confirmación se
|
||||||
// renderea arriba en `render` y maneja
|
// renderea arriba en `render` y maneja
|
||||||
// confirm/cancel.
|
// confirm/cancel.
|
||||||
this.pending_delete =
|
this.pending_delete = Some((entity_for_delete.clone(), id_copy));
|
||||||
Some((entity_for_delete.clone(), id_copy));
|
|
||||||
this.toast = None;
|
this.toast = None;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})),
|
})),
|
||||||
@@ -955,6 +933,59 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
/// click en una opción setea el TextInput del field con el UUID
|
/// click en una opción setea el TextInput del field con el UUID
|
||||||
/// seleccionado. El item del UUID actualmente seleccionado (si
|
/// seleccionado. El item del UUID actualmente seleccionado (si
|
||||||
/// hay) se resalta con accent color.
|
/// hay) se resalta con accent color.
|
||||||
|
/// Chips clickables para un campo [`FieldKind::Select`]. El chip de
|
||||||
|
/// la opción elegida se resalta con accent. Click setea el
|
||||||
|
/// `TextInput` del field (de donde lee el submit), igual que el
|
||||||
|
/// selector de EntityRef.
|
||||||
|
fn render_select_chips(
|
||||||
|
&self,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
field_name: String,
|
||||||
|
options: &[SelectOption],
|
||||||
|
text_dim: gpui::Hsla,
|
||||||
|
accent: gpui::Hsla,
|
||||||
|
) -> gpui::Div {
|
||||||
|
let theme = Theme::global(cx);
|
||||||
|
let row_active = theme.bg_row_active;
|
||||||
|
let row_hover = theme.bg_row_hover;
|
||||||
|
let border = theme.border;
|
||||||
|
let current = self
|
||||||
|
.form_inputs
|
||||||
|
.get(&field_name)
|
||||||
|
.map(|inp| inp.read(&*cx).text().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut row = div().mt(px(2.)).flex().flex_row().flex_wrap().gap(px(4.));
|
||||||
|
|
||||||
|
for opt in options {
|
||||||
|
let value = opt.value.clone();
|
||||||
|
let is_selected = current == value;
|
||||||
|
let field_for_click = field_name.clone();
|
||||||
|
let value_for_click = value.clone();
|
||||||
|
row = row.child(
|
||||||
|
div()
|
||||||
|
.id(SharedString::from(format!("select-{field_name}-{value}")))
|
||||||
|
.px(px(8.))
|
||||||
|
.py(px(3.))
|
||||||
|
.rounded(px(10.))
|
||||||
|
.border_1()
|
||||||
|
.border_color(if is_selected { accent } else { border })
|
||||||
|
.text_size(px(11.))
|
||||||
|
.text_color(if is_selected { accent } else { text_dim })
|
||||||
|
.when(is_selected, move |d| d.bg(row_active))
|
||||||
|
.hover(move |d| d.bg(row_hover))
|
||||||
|
.child(opt.display().to_string())
|
||||||
|
.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(value_for_click.clone(), cx));
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn render_entity_ref_selector(
|
fn render_entity_ref_selector(
|
||||||
&self,
|
&self,
|
||||||
@@ -1082,12 +1113,43 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
.child(label),
|
.child(label),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Render según el kind. AutoId y Select NO montan el
|
||||||
|
// TextInput editable —aunque sí existe en `form_inputs`, de
|
||||||
|
// donde lee el submit—; presentan su propio control.
|
||||||
|
match f.kind {
|
||||||
|
FieldKind::AutoId => {
|
||||||
|
// Read-only: el UUID autogenerado, sólo informativo.
|
||||||
|
let val = self
|
||||||
|
.form_inputs
|
||||||
|
.get(&f.name)
|
||||||
|
.map(|i| i.read(&*cx).text().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
field_box = field_box.child(
|
||||||
|
div()
|
||||||
|
.px(px(8.))
|
||||||
|
.py(px(6.))
|
||||||
|
.bg(input_bg)
|
||||||
|
.text_color(text_dim)
|
||||||
|
.text_size(px(11.))
|
||||||
|
.child(format!("{val} · automático")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
FieldKind::Select => {
|
||||||
|
field_box = field_box.child(self.render_select_chips(
|
||||||
|
cx,
|
||||||
|
f.name.clone(),
|
||||||
|
&f.options,
|
||||||
|
text_dim,
|
||||||
|
accent,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
// Mount del TextInput vivo (creado en select_view).
|
// Mount del TextInput vivo (creado en select_view).
|
||||||
if let Some(input) = self.form_inputs.get(&f.name) {
|
if let Some(input) = self.form_inputs.get(&f.name) {
|
||||||
field_box = field_box.child(input.clone());
|
field_box = field_box.child(input.clone());
|
||||||
} else {
|
} else {
|
||||||
// No debería pasar — select_view crea inputs por cada
|
// No debería pasar — select_view crea inputs por
|
||||||
// field. Fallback display estático por seguridad.
|
// cada field. Fallback estático por seguridad.
|
||||||
field_box = field_box.child(
|
field_box = field_box.child(
|
||||||
div()
|
div()
|
||||||
.px(px(8.))
|
.px(px(8.))
|
||||||
@@ -1097,11 +1159,8 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
.child("(input no inicializado)"),
|
.child("(input no inicializado)"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// EntityRef: selector clickable de records debajo
|
||||||
// Para EntityRef, agregamos un selector clickable de
|
// del input. Click setea el TextInput con el UUID.
|
||||||
// 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 f.kind == FieldKind::EntityRef {
|
||||||
if let Some(target_entity) = &f.ref_entity {
|
if let Some(target_entity) = &f.ref_entity {
|
||||||
field_box = field_box.child(self.render_entity_ref_selector(
|
field_box = field_box.child(self.render_entity_ref_selector(
|
||||||
@@ -1114,6 +1173,8 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(help) = &f.help {
|
if let Some(help) = &f.help {
|
||||||
field_box = field_box.child(
|
field_box = field_box.child(
|
||||||
|
|||||||
@@ -14,13 +14,13 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use gpui::TestAppContext;
|
use gpui::TestAppContext;
|
||||||
use serde_json::json;
|
|
||||||
use nahual_meta_runtime::testing::MockBackend;
|
use nahual_meta_runtime::testing::MockBackend;
|
||||||
use nahual_meta_schema::{
|
use nahual_meta_schema::{
|
||||||
Action, Column, EntitySpec, FieldKind, FieldSpec, FormView, ListView, MenuItem, Module, View,
|
Action, Column, EntitySpec, FieldKind, FieldSpec, FormView, ListView, MenuItem, Module, View,
|
||||||
};
|
};
|
||||||
use nahual_theme::Theme;
|
use nahual_theme::Theme;
|
||||||
use nahual_widget_meta_form::MetaApp;
|
use nahual_widget_meta_form::MetaApp;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
/// Helper: módulo demo simple con una entity Customer + view list.
|
/// Helper: módulo demo simple con una entity Customer + view list.
|
||||||
fn customers_module() -> Module {
|
fn customers_module() -> Module {
|
||||||
@@ -52,6 +52,7 @@ fn customers_module() -> Module {
|
|||||||
required: true,
|
required: true,
|
||||||
help: None,
|
help: None,
|
||||||
ref_entity: None,
|
ref_entity: None,
|
||||||
|
options: Vec::new(),
|
||||||
}],
|
}],
|
||||||
on_submit: Action::SeedEntity {
|
on_submit: Action::SeedEntity {
|
||||||
entity: "Customer".into(),
|
entity: "Customer".into(),
|
||||||
@@ -92,22 +93,11 @@ fn customers_module() -> Module {
|
|||||||
fn meta_app_constructs_with_mock_backend_and_initial_state(cx: &mut TestAppContext) {
|
fn meta_app_constructs_with_mock_backend_and_initial_state(cx: &mut TestAppContext) {
|
||||||
cx.update(|cx| Theme::install_default(cx));
|
cx.update(|cx| Theme::install_default(cx));
|
||||||
let id = uuid::Uuid::new_v4();
|
let id = uuid::Uuid::new_v4();
|
||||||
let backend = MockBackend::with_records([(
|
let backend = MockBackend::with_records([("Customer".into(), id, json!({"name": "Acme"}))]);
|
||||||
"Customer".into(),
|
|
||||||
id,
|
|
||||||
json!({"name": "Acme"}),
|
|
||||||
)]);
|
|
||||||
let modules = vec![customers_module()];
|
let modules = vec![customers_module()];
|
||||||
|
|
||||||
let entity = cx.add_window(|_w, cx| {
|
let entity =
|
||||||
MetaApp::new(
|
cx.add_window(|_w, cx| MetaApp::new(modules, backend, Some("hola".into()), None, cx));
|
||||||
modules,
|
|
||||||
backend,
|
|
||||||
Some("hola".into()),
|
|
||||||
None,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
let _ = entity; // mantener viva la window para el reactor.
|
let _ = entity; // mantener viva la window para el reactor.
|
||||||
}
|
}
|
||||||
@@ -123,9 +113,7 @@ fn open_view_action_does_not_panic(cx: &mut TestAppContext) {
|
|||||||
let backend = MockBackend::new();
|
let backend = MockBackend::new();
|
||||||
let modules = vec![customers_module()];
|
let modules = vec![customers_module()];
|
||||||
|
|
||||||
let window = cx.add_window(|_w, cx| {
|
let window = cx.add_window(|_w, cx| MetaApp::new(modules, backend, None, None, cx));
|
||||||
MetaApp::new(modules, backend, None, None, cx)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update vía window: ejecutar apply_action.
|
// Update vía window: ejecutar apply_action.
|
||||||
window
|
window
|
||||||
@@ -152,16 +140,10 @@ fn open_view_action_does_not_panic(cx: &mut TestAppContext) {
|
|||||||
fn backend_state_visible_from_widget_perspective(cx: &mut TestAppContext) {
|
fn backend_state_visible_from_widget_perspective(cx: &mut TestAppContext) {
|
||||||
cx.update(|cx| Theme::install_default(cx));
|
cx.update(|cx| Theme::install_default(cx));
|
||||||
let id = uuid::Uuid::new_v4();
|
let id = uuid::Uuid::new_v4();
|
||||||
let backend = MockBackend::with_records([(
|
let backend = MockBackend::with_records([("Customer".into(), id, json!({"name": "Acme"}))]);
|
||||||
"Customer".into(),
|
|
||||||
id,
|
|
||||||
json!({"name": "Acme"}),
|
|
||||||
)]);
|
|
||||||
let modules = vec![customers_module()];
|
let modules = vec![customers_module()];
|
||||||
|
|
||||||
let window = cx.add_window(|_w, cx| {
|
let window = cx.add_window(|_w, cx| MetaApp::new(modules, backend, None, None, cx));
|
||||||
MetaApp::new(modules, backend, None, None, cx)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Read directo del backend via list_records, vía la API
|
// Read directo del backend via list_records, vía la API
|
||||||
// que renders usan internamente.
|
// que renders usan internamente.
|
||||||
@@ -172,11 +154,8 @@ fn backend_state_visible_from_widget_perspective(cx: &mut TestAppContext) {
|
|||||||
// un nuevo MockBackend igual al construido devuelve el
|
// un nuevo MockBackend igual al construido devuelve el
|
||||||
// mismo record, validamos el contrato de cómo el mock
|
// mismo record, validamos el contrato de cómo el mock
|
||||||
// simula state.
|
// simula state.
|
||||||
let mock_check = MockBackend::with_records([(
|
let mock_check =
|
||||||
"Customer".into(),
|
MockBackend::with_records([("Customer".into(), id, json!({"name": "Acme"}))]);
|
||||||
id,
|
|
||||||
json!({"name": "Acme"}),
|
|
||||||
)]);
|
|
||||||
use nahual_meta_runtime::MetaBackend;
|
use nahual_meta_runtime::MetaBackend;
|
||||||
let rows = mock_check.list_records("Customer");
|
let rows = mock_check.list_records("Customer");
|
||||||
assert_eq!(rows.len(), 1);
|
assert_eq!(rows.len(), 1);
|
||||||
@@ -190,9 +169,7 @@ fn backend_state_visible_from_widget_perspective(cx: &mut TestAppContext) {
|
|||||||
/// para vivir en una `Entity` de GPUI (el bound del trait es
|
/// para vivir en una `Entity` de GPUI (el bound del trait es
|
||||||
/// `'static`; se cumple).
|
/// `'static`; se cumple).
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
fn morphism_handler_can_be_registered_and_called_via_widget(
|
fn morphism_handler_can_be_registered_and_called_via_widget(cx: &mut TestAppContext) {
|
||||||
cx: &mut TestAppContext,
|
|
||||||
) {
|
|
||||||
cx.update(|cx| Theme::install_default(cx));
|
cx.update(|cx| Theme::install_default(cx));
|
||||||
let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
let counter_clone = counter.clone();
|
let counter_clone = counter.clone();
|
||||||
@@ -205,9 +182,7 @@ fn morphism_handler_can_be_registered_and_called_via_widget(
|
|||||||
);
|
);
|
||||||
let modules = vec![customers_module()];
|
let modules = vec![customers_module()];
|
||||||
|
|
||||||
let window = cx.add_window(|_w, cx| {
|
let window = cx.add_window(|_w, cx| MetaApp::new(modules, backend, None, None, cx));
|
||||||
MetaApp::new(modules, backend, None, None, cx)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invocar un Action::Morphism vía apply_action: como el módulo
|
// Invocar un Action::Morphism vía apply_action: como el módulo
|
||||||
// demo no declara morphism + no hay nakui_module_dir, esperamos
|
// demo no declara morphism + no hay nakui_module_dir, esperamos
|
||||||
|
|||||||
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19.
|
Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19.
|
||||||
|
|
||||||
|
### feat(meta-*): FieldKind Select y AutoId (Fase 1 del ERP nakui)
|
||||||
|
|
||||||
|
La metainterfaz declarativa gana dos tipos de campo:
|
||||||
|
|
||||||
|
- **`Select`** — valor de un conjunto cerrado. `FieldSpec.options`
|
||||||
|
(valor + etiqueta opcional); `Module::validate` exige `options` no
|
||||||
|
vacío. `meta-form` lo renderiza como chips clickables (el chip
|
||||||
|
elegido resaltado), no texto libre.
|
||||||
|
- **`AutoId`** — UUID v4 autogenerado. `meta-form` lo rellena al abrir
|
||||||
|
el formulario y lo muestra read-only; el usuario no teclea ids de
|
||||||
|
idempotencia. En modo edición conserva el id del record.
|
||||||
|
|
||||||
|
`parse_field_value` trata ambos como string passthrough. Tests nuevos
|
||||||
|
en `meta-schema` (validación de Select) y `meta-runtime` (parseo).
|
||||||
|
|
||||||
### feat(nahual-widget-text-input): modo enmascarado para contraseñas
|
### feat(nahual-widget-text-input): modo enmascarado para contraseñas
|
||||||
|
|
||||||
`TextInput::with_mask()` dibuja el contenido como puntos (`•`, uno por
|
`TextInput::with_mask()` dibuja el contenido como puntos (`•`, uno por
|
||||||
|
|||||||
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
ERP categórico.
|
ERP categórico.
|
||||||
|
|
||||||
|
### feat(nakui): plan maestro del ERP + Fase 1 (captura sin fricción)
|
||||||
|
|
||||||
|
Plan maestro del subproyecto en `docs/nakui-erp-masterplan.md`: 7 fases
|
||||||
|
ordenadas para llevar nakui de "listas y formularios que funcionan" a
|
||||||
|
"ERP profesional terminado". Fase 1 implementada:
|
||||||
|
|
||||||
|
- El módulo CRM usa `select` para etapa y canal (desplegable de
|
||||||
|
opciones) y `auto_id` para los ids de idempotencia — ningún
|
||||||
|
formulario pide un UUID a mano.
|
||||||
|
- `NakuiBackend::seed` inyecta el `id` de la entity = clave del store,
|
||||||
|
así `data.id` y la clave coinciden (los schemas Nickel declaran
|
||||||
|
`id | String`); el formulario de cliente ya no necesita campo `id`.
|
||||||
|
- Tipos de campo nuevos en la metainterfaz: ver el changelog de
|
||||||
|
`nahual` (`FieldKind::Select` / `AutoId`).
|
||||||
|
|
||||||
### feat(nakui-ui): CRM como ERP — listas y formularios
|
### feat(nakui-ui): CRM como ERP — listas y formularios
|
||||||
|
|
||||||
UiModule `examples/nakui-modules/crm/module.json`: hace que el módulo
|
UiModule `examples/nakui-modules/crm/module.json`: hace que el módulo
|
||||||
|
|||||||
@@ -68,7 +68,6 @@
|
|||||||
"title": "Nuevo cliente",
|
"title": "Nuevo cliente",
|
||||||
"entity": "Cliente",
|
"entity": "Cliente",
|
||||||
"fields": [
|
"fields": [
|
||||||
{ "name": "id", "label": "ID interno", "kind": "text", "help": "Opcional; el runtime genera el UUID de la entity. Dejar vacío está bien." },
|
|
||||||
{ "name": "nombre", "label": "Nombre", "kind": "text", "required": true },
|
{ "name": "nombre", "label": "Nombre", "kind": "text", "required": true },
|
||||||
{ "name": "email", "label": "Email", "kind": "text", "required": true },
|
{ "name": "email", "label": "Email", "kind": "text", "required": true },
|
||||||
{ "name": "empresa", "label": "Empresa", "kind": "text" }
|
{ "name": "empresa", "label": "Empresa", "kind": "text" }
|
||||||
@@ -102,7 +101,7 @@
|
|||||||
"entity": "Oportunidad",
|
"entity": "Oportunidad",
|
||||||
"fields": [
|
"fields": [
|
||||||
{ "name": "cliente_ref", "label": "Cliente", "kind": "entity_ref", "ref_entity": "Cliente", "required": true, "help": "Click en un cliente de la lista para seleccionarlo." },
|
{ "name": "cliente_ref", "label": "Cliente", "kind": "entity_ref", "ref_entity": "Cliente", "required": true, "help": "Click en un cliente de la lista para seleccionarlo." },
|
||||||
{ "name": "oportunidad_id", "label": "Oportunidad UUID", "kind": "text", "required": true, "help": "UUID nuevo por cada intento (idempotencia)." },
|
{ "name": "oportunidad_id", "label": "ID de la oportunidad", "kind": "auto_id" },
|
||||||
{ "name": "titulo", "label": "Título", "kind": "text", "required": true },
|
{ "name": "titulo", "label": "Título", "kind": "text", "required": true },
|
||||||
{ "name": "monto", "label": "Monto", "kind": "number", "required": true, "default": "0" },
|
{ "name": "monto", "label": "Monto", "kind": "number", "required": true, "default": "0" },
|
||||||
{ "name": "currency", "label": "Moneda", "kind": "text", "default": "USD" },
|
{ "name": "currency", "label": "Moneda", "kind": "text", "default": "USD" },
|
||||||
@@ -122,7 +121,17 @@
|
|||||||
"entity": "Oportunidad",
|
"entity": "Oportunidad",
|
||||||
"fields": [
|
"fields": [
|
||||||
{ "name": "oportunidad_ref", "label": "Oportunidad", "kind": "entity_ref", "ref_entity": "Oportunidad", "required": true, "help": "Click en una oportunidad de la lista." },
|
{ "name": "oportunidad_ref", "label": "Oportunidad", "kind": "entity_ref", "ref_entity": "Oportunidad", "required": true, "help": "Click en una oportunidad de la lista." },
|
||||||
{ "name": "etapa", "label": "Etapa destino", "kind": "text", "required": true, "help": "prospecto | calificado | propuesta | negociacion | ganada | perdida" },
|
{
|
||||||
|
"name": "etapa", "label": "Etapa destino", "kind": "select", "required": true,
|
||||||
|
"options": [
|
||||||
|
{ "value": "prospecto", "label": "Prospecto" },
|
||||||
|
{ "value": "calificado", "label": "Calificado" },
|
||||||
|
{ "value": "propuesta", "label": "Propuesta" },
|
||||||
|
{ "value": "negociacion", "label": "Negociación" },
|
||||||
|
{ "value": "ganada", "label": "Ganada" },
|
||||||
|
{ "value": "perdida", "label": "Perdida" }
|
||||||
|
]
|
||||||
|
},
|
||||||
{ "name": "timestamp", "label": "Fecha ISO", "kind": "text", "required": true, "default": "2026-05-21T12:00:00Z" }
|
{ "name": "timestamp", "label": "Fecha ISO", "kind": "text", "required": true, "default": "2026-05-21T12:00:00Z" }
|
||||||
],
|
],
|
||||||
"on_submit": {
|
"on_submit": {
|
||||||
@@ -154,8 +163,15 @@
|
|||||||
"entity": "Interaccion",
|
"entity": "Interaccion",
|
||||||
"fields": [
|
"fields": [
|
||||||
{ "name": "cliente_ref", "label": "Cliente", "kind": "entity_ref", "ref_entity": "Cliente", "required": true, "help": "Click en un cliente de la lista." },
|
{ "name": "cliente_ref", "label": "Cliente", "kind": "entity_ref", "ref_entity": "Cliente", "required": true, "help": "Click en un cliente de la lista." },
|
||||||
{ "name": "interaccion_id", "label": "Interacción UUID", "kind": "text", "required": true, "help": "UUID nuevo por cada intento (idempotencia)." },
|
{ "name": "interaccion_id", "label": "ID de la interacción", "kind": "auto_id" },
|
||||||
{ "name": "canal", "label": "Canal", "kind": "text", "required": true, "help": "llamada | email | reunion" },
|
{
|
||||||
|
"name": "canal", "label": "Canal", "kind": "select", "required": true,
|
||||||
|
"options": [
|
||||||
|
{ "value": "llamada", "label": "Llamada" },
|
||||||
|
{ "value": "email", "label": "Email" },
|
||||||
|
{ "value": "reunion", "label": "Reunión" }
|
||||||
|
]
|
||||||
|
},
|
||||||
{ "name": "nota", "label": "Nota", "kind": "multiline" },
|
{ "name": "nota", "label": "Nota", "kind": "multiline" },
|
||||||
{ "name": "timestamp", "label": "Fecha ISO", "kind": "text", "required": true, "default": "2026-05-21T12:00:00Z" }
|
{ "name": "timestamp", "label": "Fecha ISO", "kind": "text", "required": true, "default": "2026-05-21T12:00:00Z" }
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user