feat(nakui): metainterfaz declarativa + 6 modulos ERP estandar

Salto cualitativo: Nakui pasa de "library + demos + read-only viewer
del event log" a plataforma ERP con UI dirigida por datos. Cada
modulo de negocio se declara como un module.json (sin codigo Rust
nuevo) y el runtime GPUI lo carga dinamicamente: sidebar de menus,
listas con columnas configurables, formularios de alta.

3 entregables:

1. Crate nakui-ui-schema (datos puros): Module, View::List/Form,
   FieldSpec con FieldKind {Text|Multiline|Number|Boolean|Date},
   Action {OpenView|SeedEntity|Morphism}. Module::from_path,
   Module::validate, load_modules_from_dir(dir). 6 tests unit + 4
   integration.

2. Crate nakui-ui (binario GPUI): carga modulos desde
   NAKUI_MODULES_DIR. Sidebar + main panel. List view con tabla
   weighted; form view con campos labeled + submit que ejecuta
   SeedEntity contra MemoryStore in-process compartido. Toast +
   error banner. 6 tests unit.

3. 6 modulos demo en examples/nakui-modules/:
   - customers (nombre, email, telefono, credito, notas)
   - products (SKU, nombre, categoria, precio, stock)
   - suppliers (razon social, ID fiscal, contacto, terminos pago)
   - inventory_movements (fecha, tipo, SKU, cantidad, costo, motivo)
   - sales_orders (numero, cliente, fechas, estado, totales)
   - invoices (numero, cliente, fechas, totales, pagado, moneda)

Filosofia: UI como datos. Persistencia universal (MemoryStore hoy,
SurrealStore manana, sin tocar module.json). Schema primero, semantica
despues.

Activacion:
  NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui

Limitaciones conocidas (proximos iters):
- Inputs sin teclado (GPUI no lo trae nativo; integrar
  yahweh-widget-text-input).
- Click handlers no propagan mutacion al estado (refactor con
  cx.listener pendiente).
- Action::Morphism queda como TODO hasta cargar Manifest junto al
  Module.
- Sin persistencia entre runs (wire con EventLog/SurrealStore para
  cuando el daemon Nakui exista).

Tests: 16 totales nuevos. Lo que esto desbloquea: cualquiera puede
escribir un module.json para su dominio (pacientes, alumnos,
reservaciones) y aparece en la UI sin recompilar.
This commit is contained in:
Sergio
2026-05-09 19:54:21 +00:00
parent 5b8f71e0de
commit 06c4fb9130
14 changed files with 1919 additions and 0 deletions
+499
View File
@@ -0,0 +1,499 @@
//! Schema declarativo de la metainterfaz Nakui.
//!
//! Cada **módulo** Nakui (customers, products, sales, ...) declara
//! aquí qué menús, vistas, listas y formularios expone, sin escribir
//! código GPUI ni Rust. El runtime ([`nakui-ui` crate]) lee estos
//! `module.json` desde un directorio y monta automáticamente la UI
//! correspondiente.
//!
//! ## Filosofía
//!
//! - **UI como datos**: agregar un módulo = escribir un JSON. Ningún
//! recompile, ningún acoplamiento con el binario del runtime.
//! - **Persistencia universal**: el runtime conecta cada vista al
//! `nakui_core::store::Store` actual; los formularios escriben
//! ops por la pipeline normal de Nakui (executor + event log).
//! - **Schema primero, semántica después**: este crate sólo define
//! la *forma* de los manifests. Validación semántica
//! (referencias rotas a entities, campos faltantes, etc.) vive en
//! el runtime que lo carga, no acá.
//!
//! ## Anatomía de un módulo
//!
//! ```json
//! {
//! "id": "customers",
//! "label": "Clientes",
//! "entities": [
//! { "name": "customer", "fields": [ ... ] }
//! ],
//! "menu": [
//! { "label": "Listar", "view": "list" },
//! { "label": "Nuevo", "view": "form" }
//! ],
//! "views": {
//! "list": { "kind": "list", "entity": "customer", "columns": [...] },
//! "form": { "kind": "form", "entity": "customer", "fields": [...] }
//! }
//! }
//! ```
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Manifiesto de un módulo de la metainterfaz Nakui.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Module {
/// Identificador estable. Único dentro del directorio cargado.
pub id: String,
/// Nombre legible para mostrar en el sidebar.
pub label: String,
/// Descripción corta opcional (tooltip / subtítulo).
#[serde(default)]
pub description: Option<String>,
/// Entities que el módulo introduce o consume. El runtime las
/// usa para validar columns/fields y para inicializar el store
/// cuando son nuevas.
#[serde(default)]
pub entities: Vec<EntitySpec>,
/// Items del menú. Cada uno apunta a una key de `views`. Orden
/// importa (es el orden en que se presentan en el sidebar).
pub menu: Vec<MenuItem>,
/// Vistas indexadas por key. Las keys son referenciadas por
/// `MenuItem.view` y por `Action::OpenView.view`.
pub views: BTreeMap<String, View>,
}
/// Item del menú lateral.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MenuItem {
pub label: String,
/// Key de la vista a abrir. Debe existir en `Module.views`.
pub view: String,
/// Icono opcional (texto unicode o emoji; el runtime decide
/// renderización).
#[serde(default)]
pub icon: Option<String>,
}
/// Una vista renderizada en el área principal cuando su menú es seleccionado.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum View {
/// Tabla de instancias de una entity, columnas + acciones por fila.
List(ListView),
/// Formulario de creación / edición.
Form(FormView),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListView {
pub title: String,
/// Entity (del nakui store) cuyas instancias se listan.
pub entity: String,
/// Columnas a mostrar. Orden importa.
pub columns: Vec<Column>,
/// Acciones disponibles a nivel de la lista (ej. "Nuevo" → form).
/// Renderizadas como botones en el header.
#[serde(default)]
pub actions: Vec<Action>,
/// Cuando está set, se muestra una caja de búsqueda que filtra
/// las filas por substring contra los valores de estas columnas.
#[serde(default)]
pub search_in: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Column {
/// Path del campo dentro del record. Para tipos planos: nombre.
/// Para nested: puntos (`address.city`). El runtime navega.
pub field: String,
/// Texto del header.
pub label: String,
/// Ancho relativo (peso flex). Default 1.
#[serde(default = "default_weight")]
pub weight: f32,
}
fn default_weight() -> f32 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormView {
pub title: String,
/// Entity destino del seed/morphism al submit.
pub entity: String,
pub fields: Vec<FieldSpec>,
/// Acción al submit. Típicamente `Action::SeedEntity` para alta
/// directa o `Action::Morphism` cuando hay validación/cálculo.
pub on_submit: Action,
}
/// Especificación de un campo de formulario o columna implícita.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldSpec {
/// Nombre del campo en el record (clave del JSON).
pub name: String,
/// Etiqueta legible.
pub label: String,
/// Tipo del valor — define el widget de input + parseo.
pub kind: FieldKind,
/// Valor por defecto al abrir el form (string raw; el parseo
/// según `kind` lo hace el runtime).
#[serde(default)]
pub default: Option<String>,
/// Si `true`, el form rechaza submit con campo vacío.
#[serde(default)]
pub required: bool,
/// Texto de ayuda mostrado bajo el input.
#[serde(default)]
pub help: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FieldKind {
/// Texto libre.
Text,
/// Texto multilínea.
Multiline,
/// Número (i64 o f64; runtime intenta parsear como i64 primero).
Number,
/// Booleano (renderizado como checkbox).
Boolean,
/// Fecha (formato ISO YYYY-MM-DD; almacenada como string).
Date,
}
/// Acciones disparables por menús, botones o submit de formularios.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Action {
/// Cambia la vista activa a otra del mismo módulo.
OpenView {
view: String,
/// Etiqueta del botón / item; default = nombre humano de la vista.
#[serde(default)]
label: Option<String>,
},
/// Crea un seed directo en la entity con los valores del form.
/// Equivalente a `nakui_core::event_log::seed_and_log`.
/// Sin pipeline de morphism — alta administrativa.
SeedEntity {
entity: String,
/// Tras el submit exitoso, opcionalmente abrir esta vista
/// (por convención: `"list"` para volver al listado).
#[serde(default)]
next_view: Option<String>,
},
/// Ejecuta un morphism declarado en el manifest del módulo
/// nakui-core. Inputs y params se mapean desde los campos del form.
Morphism {
name: String,
#[serde(default)]
next_view: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntitySpec {
/// Nombre de la entity (clave en el store). Único dentro del módulo.
pub name: String,
/// Label legible (singular).
pub label: String,
/// Campos esperados en records de esta entity. Usados por el
/// runtime para inferir columnas / validar formularios.
#[serde(default)]
pub fields: Vec<FieldSpec>,
}
#[derive(Debug, Error)]
pub enum SchemaError {
#[error("io leyendo {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("parseo de {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("módulo {id}: la vista '{view}' referenciada por menu_item '{item}' no existe")]
DanglingMenuView {
id: String,
item: String,
view: String,
},
#[error("módulos con id duplicado: '{id}' aparece en {first} y {second}")]
DuplicateModuleId {
id: String,
first: PathBuf,
second: PathBuf,
},
}
impl Module {
/// Carga un module.json desde disco. Validación estructural
/// posterior (vistas referenciadas existen, etc.) la ejecuta el
/// runtime — acá sólo parseamos.
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SchemaError> {
let path = path.as_ref();
let bytes = std::fs::read(path).map_err(|source| SchemaError::Io {
path: path.to_path_buf(),
source,
})?;
serde_json::from_slice(&bytes).map_err(|source| SchemaError::Parse {
path: path.to_path_buf(),
source,
})
}
/// Validación post-parse: cada `MenuItem.view` debe existir en
/// `views`. Retorna el primer error encontrado.
pub fn validate(&self) -> Result<(), SchemaError> {
for item in &self.menu {
if !self.views.contains_key(&item.view) {
return Err(SchemaError::DanglingMenuView {
id: self.id.clone(),
item: item.label.clone(),
view: item.view.clone(),
});
}
}
Ok(())
}
}
/// Carga todos los `module.json` encontrados bajo `dir` (recursivo
/// 1 nivel — espera `dir/<modulo>/module.json`). Devuelve la lista
/// ordenada por id, validada.
///
/// Falla si: I/O, parseo, módulo inválido, o ids duplicados.
pub fn load_modules_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Module>, SchemaError> {
let dir = dir.as_ref();
let mut modules: Vec<(PathBuf, Module)> = Vec::new();
let entries = std::fs::read_dir(dir).map_err(|source| SchemaError::Io {
path: dir.to_path_buf(),
source,
})?;
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
let manifest = p.join("module.json");
if manifest.exists() {
let m = Module::from_path(&manifest)?;
m.validate()?;
modules.push((manifest, m));
}
}
}
// Detectar duplicados de id.
modules.sort_by(|a, b| a.1.id.cmp(&b.1.id));
let mut prev: Option<&(PathBuf, Module)> = None;
for cur in &modules {
if let Some(p) = prev {
if p.1.id == cur.1.id {
return Err(SchemaError::DuplicateModuleId {
id: cur.1.id.clone(),
first: p.0.clone(),
second: cur.0.clone(),
});
}
}
prev = Some(cur);
}
Ok(modules.into_iter().map(|(_, m)| m).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_module() -> Module {
Module {
id: "customers".into(),
label: "Clientes".into(),
description: Some("Gestión de clientes".into()),
entities: vec![EntitySpec {
name: "customer".into(),
label: "Cliente".into(),
fields: vec![
FieldSpec {
name: "name".into(),
label: "Nombre".into(),
kind: FieldKind::Text,
default: None,
required: true,
help: None,
},
FieldSpec {
name: "email".into(),
label: "Email".into(),
kind: FieldKind::Text,
default: None,
required: false,
help: Some("Opcional".into()),
},
],
}],
menu: vec![
MenuItem {
label: "Listar".into(),
view: "list".into(),
icon: None,
},
MenuItem {
label: "Nuevo".into(),
view: "form".into(),
icon: None,
},
],
views: BTreeMap::from([
(
"list".into(),
View::List(ListView {
title: "Clientes".into(),
entity: "customer".into(),
columns: vec![
Column {
field: "name".into(),
label: "Nombre".into(),
weight: 2.0,
},
Column {
field: "email".into(),
label: "Email".into(),
weight: 3.0,
},
],
actions: vec![Action::OpenView {
view: "form".into(),
label: Some("Nuevo".into()),
}],
search_in: vec!["name".into(), "email".into()],
}),
),
(
"form".into(),
View::Form(FormView {
title: "Nuevo cliente".into(),
entity: "customer".into(),
fields: vec![FieldSpec {
name: "name".into(),
label: "Nombre".into(),
kind: FieldKind::Text,
default: None,
required: true,
help: None,
}],
on_submit: Action::SeedEntity {
entity: "customer".into(),
next_view: Some("list".into()),
},
}),
),
]),
}
}
#[test]
fn module_serialize_deserialize_roundtrip() {
let m = sample_module();
let s = serde_json::to_string_pretty(&m).unwrap();
let m2: Module = serde_json::from_str(&s).unwrap();
assert_eq!(m.id, m2.id);
assert_eq!(m.menu.len(), m2.menu.len());
assert_eq!(m.views.len(), m2.views.len());
}
#[test]
fn validate_passes_for_well_formed_module() {
sample_module().validate().unwrap();
}
#[test]
fn validate_catches_dangling_menu_view() {
let mut m = sample_module();
m.menu.push(MenuItem {
label: "Roto".into(),
view: "no_existe".into(),
icon: None,
});
let err = m.validate().unwrap_err();
assert!(matches!(err, SchemaError::DanglingMenuView { .. }));
}
#[test]
fn from_path_loads_real_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("module.json");
std::fs::write(&path, serde_json::to_vec_pretty(&sample_module()).unwrap()).unwrap();
let m = Module::from_path(&path).unwrap();
assert_eq!(m.id, "customers");
}
#[test]
fn load_modules_from_dir_finds_subdirs() {
let tmp = tempfile::tempdir().unwrap();
let cust_dir = tmp.path().join("customers");
std::fs::create_dir(&cust_dir).unwrap();
let mut m1 = sample_module();
m1.id = "customers".into();
std::fs::write(
cust_dir.join("module.json"),
serde_json::to_vec_pretty(&m1).unwrap(),
)
.unwrap();
let prod_dir = tmp.path().join("products");
std::fs::create_dir(&prod_dir).unwrap();
let mut m2 = sample_module();
m2.id = "products".into();
std::fs::write(
prod_dir.join("module.json"),
serde_json::to_vec_pretty(&m2).unwrap(),
)
.unwrap();
let mods = load_modules_from_dir(tmp.path()).unwrap();
assert_eq!(mods.len(), 2);
assert_eq!(mods[0].id, "customers");
assert_eq!(mods[1].id, "products");
}
#[test]
fn load_modules_detects_duplicate_id() {
let tmp = tempfile::tempdir().unwrap();
let a_dir = tmp.path().join("a");
let b_dir = tmp.path().join("b");
std::fs::create_dir_all(&a_dir).unwrap();
std::fs::create_dir_all(&b_dir).unwrap();
let m = sample_module(); // id = "customers"
std::fs::write(
a_dir.join("module.json"),
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();
assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
}
}