diff --git a/Cargo.lock b/Cargo.lock index e00901ac..30f588d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1884,10 +1884,8 @@ dependencies = [ "chrono", "pagetop", "pagetop-seaorm", - "sea-orm", "serde", "thiserror", - "tokio", ] [[package]] diff --git a/extensions/pagetop-menu/Cargo.toml b/extensions/pagetop-menu/Cargo.toml new file mode 100644 index 00000000..bd8d676d --- /dev/null +++ b/extensions/pagetop-menu/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "pagetop-menu" +description = "Gestión centralizada y persistente de menús para PageTop." +version = "0.1.0" +categories = ["web-programming", "development-tools"] +keywords = ["pagetop", "menu", "navigation", "cms", "ssr"] + +repository.workspace = true +homepage.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +pagetop.workspace = true +pagetop-seaorm.workspace = true +chrono.workspace = true +serde.workspace = true +thiserror.workspace = true diff --git a/extensions/pagetop-menu/src/action.rs b/extensions/pagetop-menu/src/action.rs new file mode 100644 index 00000000..3dce5434 --- /dev/null +++ b/extensions/pagetop-menu/src/action.rs @@ -0,0 +1,312 @@ +//! Tipos de acción que `pagetop-menu` expone para que otras extensiones extiendan el sistema. +//! +//! El flujo de `build_tree()` despacha estas acciones en este orden: +//! +//! 1. `AlterMenuTree` - filtros, reordenamientos, inyección de nodos en caliente. +//! 2. Cálculo del active trail por URL. +//! 3. `ResolveActiveTrail` - matching personalizado para rutas paramétricas. +//! 4. `DecorateMenuItem` - añadir atributos HTML a nodos individuales. +//! +//! El sembrado inicial usa `DeclareDefaultMenus` y `DeclareDefaultMenuItems`. + +use pagetop::prelude::*; + +use crate::repo::NewMenuItem; +use crate::tree::{MenuNode, MenuTree}; + +// **< Tipo de callbacks >************************************************************************** + +pub type FnMenuDefs = fn(&mut MenuDefs); +pub type FnItemBag = fn(&mut ItemBag); +pub type FnAlterTree = fn(&mut MenuTree, &Context); +pub type FnDecorate = fn(&mut MenuNode, &Context); + +// **< MenuDefs >*********************************************************************************** + +/// Bolsa de declaraciones de menú para `DeclareDefaultMenus`. +pub struct MenuDefs { + pub(crate) entries: Vec<(String, String)>, +} + +impl MenuDefs { + /// Declara que el menú `machine_name` debe existir con el título dado. + /// Si ya existe en la lista, no se añade de nuevo. + pub fn ensure(&mut self, machine_name: impl Into, title: impl Into) { + let name = machine_name.into(); + if !self.entries.iter().any(|(n, _)| n == &name) { + self.entries.push((name, title.into())); + } + } +} + +// **< ItemBag >************************************************************************************ + +/// Bolsa de declaraciones de ítems para `DeclareDefaultMenuItems`. +pub struct ItemBag { + pub(crate) items: Vec, +} + +impl ItemBag { + /// Añade un ítem declarado por código a la bolsa. + pub fn add(&mut self, item: NewMenuItem) { + self.items.push(item); + } +} + +// **< DeclareDefaultMenus >************************************************************************ + +/// Acción para declarar los menús que una extensión necesita. +/// +/// Se despacha durante el sembrado inicial (`seed::run()`). El callback recibe un +/// [`MenuDefs`] y puede llamar a `ensure()` para declarar los menús necesarios. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop_menu::action::{DeclareDefaultMenus, MenuDefs}; +/// +/// fn my_menus(defs: &mut MenuDefs) { +/// defs.ensure("main", "Main navigation"); +/// defs.ensure("footer", "Footer links"); +/// } +/// // En Extension::actions(): +/// // DeclareDefaultMenus::new(my_menus) +/// ``` +pub struct DeclareDefaultMenus { + f: FnMenuDefs, + weight: Weight, +} + +impl ActionDispatcher for DeclareDefaultMenus { + fn weight(&self) -> Weight { + self.weight + } +} + +impl DeclareDefaultMenus { + pub fn new(f: FnMenuDefs) -> Self { + DeclareDefaultMenus { f, weight: 0 } + } + + pub fn with_weight(mut self, w: Weight) -> Self { + self.weight = w; + self + } + + pub(crate) fn dispatch(defs: &mut MenuDefs) { + dispatch_actions( + &ActionKey::new(UniqueId::of::(), None, None), + |action: &Self| (action.f)(defs), + ); + } +} + +// **< DeclareDefaultMenuItems >******************************************************************** + +/// Acción para declarar los ítems por defecto de un menú concreto. +/// +/// El `referer_id` es el `machine_name` del menú al que pertenecen los ítems. +/// Se despacha durante el sembrado inicial (`seed::run()`) para cada menú conocido. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop_menu::action::{DeclareDefaultMenuItems, ItemBag}; +/// use pagetop_menu::NewMenuItem; +/// +/// fn blog_items(bag: &mut ItemBag) { +/// bag.add(NewMenuItem::new() +/// .with_provider("my-blog") +/// .with_external_key("blog.index") +/// .with_title("Blog") +/// .with_url("/blog") +/// .with_weight(10)); +/// } +/// // En Extension::actions(): +/// // DeclareDefaultMenuItems::new("main", blog_items) +/// ``` +pub struct DeclareDefaultMenuItems { + menu_name: String, + f: FnItemBag, + weight: Weight, +} + +impl ActionDispatcher for DeclareDefaultMenuItems { + fn referer_id(&self) -> Option { + Some(self.menu_name.clone()) + } + + fn weight(&self) -> Weight { + self.weight + } +} + +impl DeclareDefaultMenuItems { + pub fn new(menu_name: impl Into, f: FnItemBag) -> Self { + DeclareDefaultMenuItems { + menu_name: menu_name.into(), + f, + weight: 0, + } + } + + pub fn with_weight(mut self, w: Weight) -> Self { + self.weight = w; + self + } + + pub(crate) fn dispatch(menu_name: &str, bag: &mut ItemBag) { + dispatch_actions( + &ActionKey::new(UniqueId::of::(), None, Some(menu_name.to_owned())), + |action: &Self| (action.f)(bag), + ); + } +} + +// **< AlterMenuTree >****************************************************************************** + +/// Acción para modificar el árbol de un menú antes de calcularse el active trail. +/// +/// El `referer_id` es el `machine_name` del menú. Las acciones con `referer_id` `None` +/// no se registran aquí; usa un `machine_name` específico por menú. +/// +/// Usos habituales: filtrar nodos por permisos, añadir nodos dinámicos, reordenar. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop_menu::action::AlterMenuTree; +/// use pagetop_menu::tree::MenuTree; +/// use pagetop::prelude::*; +/// +/// fn hide_disabled(tree: &mut MenuTree, _cx: &Context) { +/// tree.roots.retain(|n| n.enabled); +/// } +/// // En Extension::actions(): +/// // AlterMenuTree::new("main", hide_disabled) +/// ``` +pub struct AlterMenuTree { + menu_name: String, + f: FnAlterTree, + weight: Weight, +} + +impl ActionDispatcher for AlterMenuTree { + fn referer_id(&self) -> Option { + Some(self.menu_name.clone()) + } + + fn weight(&self) -> Weight { + self.weight + } +} + +impl AlterMenuTree { + pub fn new(menu_name: impl Into, f: FnAlterTree) -> Self { + AlterMenuTree { + menu_name: menu_name.into(), + f, + weight: 0, + } + } + + pub fn with_weight(mut self, w: Weight) -> Self { + self.weight = w; + self + } + + pub(crate) fn dispatch(menu_name: &str, tree: &mut MenuTree, cx: &Context) { + dispatch_actions( + &ActionKey::new(UniqueId::of::(), None, Some(menu_name.to_owned())), + |action: &Self| (action.f)(tree, cx), + ); + } +} + +// **< ResolveActiveTrail >************************************************************************* + +/// Acción para resolver el active trail en casos que la coincidencia por URL no cubre. +/// +/// Se despacha después del cálculo automático de active trail. Útil para rutas +/// paramétricas (p. ej., `/blog/{slug}` que debe marcar activo al ítem `/blog`). +pub struct ResolveActiveTrail { + menu_name: String, + f: FnAlterTree, + weight: Weight, +} + +impl ActionDispatcher for ResolveActiveTrail { + fn referer_id(&self) -> Option { + Some(self.menu_name.clone()) + } + + fn weight(&self) -> Weight { + self.weight + } +} + +impl ResolveActiveTrail { + pub fn new(menu_name: impl Into, f: FnAlterTree) -> Self { + ResolveActiveTrail { + menu_name: menu_name.into(), + f, + weight: 0, + } + } + + pub fn with_weight(mut self, w: Weight) -> Self { + self.weight = w; + self + } + + pub(crate) fn dispatch(menu_name: &str, tree: &mut MenuTree, cx: &Context) { + dispatch_actions( + &ActionKey::new(UniqueId::of::(), None, Some(menu_name.to_owned())), + |action: &Self| (action.f)(tree, cx), + ); + } +} + +// **< DecorateMenuItem >*************************************************************************** + +/// Acción para decorar nodos individuales antes del render (atributos HTML, iconos, badges). +/// +/// Se despacha para cada nodo del árbol después del cálculo del active trail, +/// por lo que el callback puede leer `node.is_active` y `node.in_active_trail`. +pub struct DecorateMenuItem { + menu_name: String, + f: FnDecorate, + weight: Weight, +} + +impl ActionDispatcher for DecorateMenuItem { + fn referer_id(&self) -> Option { + Some(self.menu_name.clone()) + } + + fn weight(&self) -> Weight { + self.weight + } +} + +impl DecorateMenuItem { + pub fn new(menu_name: impl Into, f: FnDecorate) -> Self { + DecorateMenuItem { + menu_name: menu_name.into(), + f, + weight: 0, + } + } + + pub fn with_weight(mut self, w: Weight) -> Self { + self.weight = w; + self + } + + pub(crate) fn dispatch(menu_name: &str, node: &mut MenuNode, cx: &Context) { + dispatch_actions( + &ActionKey::new(UniqueId::of::(), None, Some(menu_name.to_owned())), + |action: &Self| (action.f)(node, cx), + ); + } +} diff --git a/extensions/pagetop-menu/src/cache.rs b/extensions/pagetop-menu/src/cache.rs new file mode 100644 index 00000000..599c9c5f --- /dev/null +++ b/extensions/pagetop-menu/src/cache.rs @@ -0,0 +1,73 @@ +//! Caché in-process de los ítems de menú por `machine_name`. +//! +//! Almacena la lista plana de modelos y sus traducciones para cada menú. El árbol se +//! reconstruye en memoria en cada petición a partir de esta estructura. Hoy no existe ningún +//! camino que escriba ítems tras el sembrado inicial (`seed::run()`, antes de la primera +//! petición), así que no hace falta invalidación: cuando exista una vía de escritura en caliente +//! (p. ej. una UI de administración), deberá invalidar la entrada correspondiente aquí. + +use std::collections::HashMap; +use std::sync::LazyLock; +use std::sync::{Arc, RwLock}; + +use crate::entity::{menu_item, menu_item_translation}; + +/// Contenido plano de un menú listo para construir el árbol. +pub struct FlatMenu { + pub items: Vec, + /// Traducciones indexadas por `item_id`. + pub translations: HashMap>, +} + +static CACHE: LazyLock>>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +/// Devuelve el contenido cacheado del menú dado, o lo carga desde BD si no está. +pub async fn get_or_load(menu_id: i32, machine_name: &str) -> Arc { + { + let guard = CACHE.read().expect("cache read lock poisoned"); + if let Some(flat) = guard.get(machine_name) { + return Arc::clone(flat); + } + } + + let flat = load_from_db(menu_id).await; + let arc = Arc::new(flat); + CACHE + .write() + .expect("cache write lock poisoned") + .insert(machine_name.to_owned(), Arc::clone(&arc)); + arc +} + +async fn load_from_db(menu_id: i32) -> FlatMenu { + use pagetop_seaorm::db::{ColumnTrait, EntityTrait, QueryFilter, dbconn}; + + let items = menu_item::Entity::find() + .filter(menu_item::Column::MenuId.eq(menu_id)) + .all(dbconn()) + .await + .unwrap_or_default(); + + let item_ids: Vec = items.iter().map(|i| i.id).collect(); + + let all_translations = if item_ids.is_empty() { + vec![] + } else { + menu_item_translation::Entity::find() + .filter(menu_item_translation::Column::ItemId.is_in(item_ids)) + .all(dbconn()) + .await + .unwrap_or_default() + }; + + let mut translations: HashMap> = HashMap::new(); + for t in all_translations { + translations.entry(t.item_id).or_default().push(t); + } + + FlatMenu { + items, + translations, + } +} diff --git a/extensions/pagetop-menu/src/component.rs b/extensions/pagetop-menu/src/component.rs new file mode 100644 index 00000000..01804c2d --- /dev/null +++ b/extensions/pagetop-menu/src/component.rs @@ -0,0 +1,7 @@ +//! Componentes de renderizado de menús. + +mod menu_block; +mod menu_breadcrumb; + +pub use menu_block::MenuBlock; +pub use menu_breadcrumb::MenuBreadcrumb; diff --git a/extensions/pagetop-menu/src/component/menu_block.rs b/extensions/pagetop-menu/src/component/menu_block.rs new file mode 100644 index 00000000..afcd79b4 --- /dev/null +++ b/extensions/pagetop-menu/src/component/menu_block.rs @@ -0,0 +1,198 @@ +use pagetop::prelude::*; + +use crate::tree::{MenuKey, MenuNode, TreeOptions, build_tree, try_resolve_menu_url}; + +/// Renderiza un menú completo como bloque de navegación HTML. +/// +/// Se construye componiendo [`Nav`] y [`nav::Item`] -- con [`Dropdown`]/[`dropdown::Item`] para +/// los nodos con hijos -- a partir del árbol del menú, así que produce el mismo marcado accesible +/// que cualquier `Nav`/`Dropdown` y se beneficia igual del CSS/JavaScript que aporta el tema +/// activo: +/// +/// ```html +/// +/// ``` +/// +/// Los temas pueden sobreescribir el render con `handle_component()`, tanto de `MenuBlock` como, +/// más generalmente, de [`Nav`]/[`nav::Item`]/[`Dropdown`]/[`dropdown::Item`]. `pagetop-bootsier` +/// ya intercepta `Dropdown` así (ver `theme::bs::dropdown`), y por tanto también los que cuelguen +/// de un `nav::Item::dropdown()`; `MenuBlock`, `Nav` y `Navbar` siguen sin interceptarse: Bootsier +/// mantiene sus propios `bs::Navbar`/`bs::Nav`, sin relación con este componente. +/// +/// # Limitaciones conocidas +/// +/// - **Profundidad máxima de 2 niveles.** [`Dropdown`] no admite submenús anidados (como +/// Bootstrap, del que toma su marcado): un nodo de tercer nivel o más profundo nunca se +/// construye -- [`TreeOptions::max_depth`] se acota internamente a `2` con independencia de lo +/// que indique [`with_max_depth()`](Self::with_max_depth), así que no hay pérdida silenciosa de +/// datos, sencillamente no se piden a la base de datos. +/// - **Sin colapso responsive propio.** A diferencia del antiguo `Menu::collapsible`, `Nav` es una +/// lista plana sin botón ni JavaScript de colapso; una aplicación que necesite ese +/// comportamiento debe envolver `MenuBlock` en su propia chrome hasta que exista un componente +/// `Navbar` en el core. +/// - `MenuNode::in_active_trail` y `MenuNode::expanded` (ver [`crate::tree::MenuNode`]) todavía no +/// se reflejan en el marcado -- ni [`nav::Item`] ni [`dropdown::Item`] tienen hoy una forma +/// verificada de pre-abrirse en el servidor sin que la mejora progresiva del tema +/// (`accessible-menu` en `Basic`) lo sobrescriba al inicializarse. Sólo se traduce el estado +/// `is_active`/`enabled` de cada nodo. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// use pagetop_menu::component::MenuBlock; +/// +/// async fn handler(request: HttpRequest) -> Result { +/// Page::new(request) +/// .with_child(MenuBlock::with("main")) +/// .render().await +/// } +/// ``` +#[derive(AutoDefault, Clone, Debug)] +pub struct MenuBlock { + menu_name: Option, + show_title: bool, + max_depth: Option, + include_disabled: bool, + hide_when_empty: bool, +} + +#[async_trait] +impl Component for MenuBlock { + fn new() -> Self { + MenuBlock { + hide_when_empty: true, + ..Self::default() + } + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let Some(name) = &self.menu_name else { + return Ok(html! {}); + }; + + // `Dropdown` no admite submenús: nunca se piden más de 2 niveles al árbol. + let opts = TreeOptions { + max_depth: Some(self.max_depth.map(|d| d.min(2)).unwrap_or(2)), + include_disabled: self.include_disabled, + }; + + let Some(tree) = build_tree(MenuKey::Name(name.clone()), cx, &opts).await else { + return Ok(html! {}); + }; + + if tree.roots.is_empty() && self.hide_when_empty { + return Ok(html! {}); + } + + let mut nav = Nav::new(); + for node in &tree.roots { + nav = nav.with_item(node_to_item(node, cx)); + } + + let aria_label = (!tree.title.is_empty()).then_some(&tree.title); + + Ok(html! { + @if self.show_title { + h2.menu-title { (&tree.title) } + } + nav aria-label=[aria_label] { + (nav.render(cx).await) + } + }) + } +} + +impl MenuBlock { + /// Crea un `MenuBlock` para el menú con el `machine_name` dado. + pub fn with(menu_name: impl Into) -> Self { + let mut block = Self::new(); + block.menu_name = Some(menu_name.into()); + block + } + + #[builder_fn] + pub fn with_show_title(mut self, v: impl Into>) -> Self { + if let Some(v) = v.into() { + self.show_title = v; + } + self + } + + /// Establece la profundidad máxima de nodos a incluir. El efectivo nunca supera `2`, por muy + /// alto que sea el valor indicado (ver "Limitaciones conocidas" en [`MenuBlock`]). + #[builder_fn] + pub fn with_max_depth(mut self, v: impl Into>) -> Self { + self.max_depth = v.into(); + self + } + + #[builder_fn] + pub fn with_include_disabled(mut self, v: impl Into>) -> Self { + if let Some(v) = v.into() { + self.include_disabled = v; + } + self + } + + #[builder_fn] + pub fn with_hide_when_empty(mut self, v: impl Into>) -> Self { + if let Some(v) = v.into() { + self.hide_when_empty = v; + } + self + } +} + +// **< Traducción de MenuNode a nav::Item / dropdown::Item >**************************************** + +// Convierte un `MenuNode` de nivel 1 (raíz) en un `nav::Item`: sin hijos, enlace (o etiqueta sin +// ruta propia); con hijos, activador de un `Dropdown` con sus hijos como `dropdown::Item`. Los +// hijos de un `MenuNode` de nivel 1 nunca tienen a su vez hijos propios -- `TreeOptions::max_depth` +// se acota a 2 en `MenuBlock::prepare()`, así que no hay un tercer nivel que representar. +fn node_to_item(node: &MenuNode, cx: &Context) -> nav::Item { + if !node.children.is_empty() { + let mut dropdown = Dropdown::new().with_title(Lc::n(node.title.clone())); + for child in &node.children { + dropdown = dropdown.with_item(child_to_item(child, cx)); + } + return nav::Item::dropdown(dropdown); + } + + let label = Lc::n(node.title.clone()); + let is_external = node.url.as_ref().is_some_and(RoutePath::is_external); + let disabled = !node.enabled; + + let Some(route) = try_resolve_menu_url(node.url.as_ref(), cx).map(Route::from) else { + return nav::Item::label(label); + }; + match (is_external, disabled) { + (true, true) => nav::Item::link_blank_disabled(label, route), + (true, false) => nav::Item::link_blank(label, route), + (false, true) => nav::Item::link_disabled(label, route), + (false, false) => nav::Item::link(label, route), + } + .with_active(node.is_active) +} + +// Convierte un `MenuNode` de nivel 2 en un `dropdown::Item`: sin ruta, etiqueta no interactiva; con +// ruta, enlace. `Dropdown` no admite submenús, así que no hay recursión posible aquí. +fn child_to_item(node: &MenuNode, cx: &Context) -> dropdown::Item { + let label = Lc::n(node.title.clone()); + let is_external = node.url.as_ref().is_some_and(RoutePath::is_external); + let disabled = !node.enabled; + + let Some(route) = try_resolve_menu_url(node.url.as_ref(), cx).map(Route::from) else { + return dropdown::Item::label(label); + }; + match (is_external, disabled) { + (true, true) => dropdown::Item::link_blank_disabled(label, route), + (true, false) => dropdown::Item::link_blank(label, route), + (false, true) => dropdown::Item::link_disabled(label, route), + (false, false) => dropdown::Item::link(label, route), + } +} diff --git a/extensions/pagetop-menu/src/component/menu_breadcrumb.rs b/extensions/pagetop-menu/src/component/menu_breadcrumb.rs new file mode 100644 index 00000000..9d9d95e2 --- /dev/null +++ b/extensions/pagetop-menu/src/component/menu_breadcrumb.rs @@ -0,0 +1,103 @@ +use pagetop::base::component::breadcrumb; +use pagetop::prelude::*; + +use crate::tree::{MenuKey, MenuNode, TreeOptions, build_tree, try_resolve_menu_url}; + +/// Migas de pan del menú dado, basándose en el active trail. +/// +/// Resuelve el árbol del menú y su active trail (de forma asíncrona, en su propio +/// [`prepare()`](Component::prepare)) y delega el renderizado en +/// [`Breadcrumb`](pagetop::base::component::Breadcrumb): esta extensión sólo aporta los datos, la +/// estructura HTML y las clases CSS son responsabilidad del componente base. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// use pagetop_menu::component::MenuBreadcrumb; +/// +/// async fn handler(request: HttpRequest) -> Result { +/// Page::new(request) +/// .with_child(MenuBreadcrumb::with("main")) +/// .render().await +/// } +/// ``` +#[derive(AutoDefault, Clone, Debug)] +pub struct MenuBreadcrumb { + menu_name: Option, + include_current: bool, +} + +#[async_trait] +impl Component for MenuBreadcrumb { + fn new() -> Self { + MenuBreadcrumb { + include_current: true, + ..Self::default() + } + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let Some(name) = &self.menu_name else { + return Ok(html! {}); + }; + + let opts = TreeOptions::default(); + let Some(tree) = build_tree(MenuKey::Name(name.clone()), cx, &opts).await else { + return Ok(html! {}); + }; + + let path = extract_trail(&tree.roots); + if path.is_empty() { + return Ok(html! {}); + } + + let mut inner = Breadcrumb::new(); + let last = path.len() - 1; + for (i, node) in path.iter().enumerate() { + let label = Lc::n(node.title.clone()); + if i == last { + if self.include_current { + inner = inner.with_crumb(breadcrumb::Crumb::current(label)); + } + } else if let Some(url) = node.url.clone() { + let route = + Route::with(move |cx| try_resolve_menu_url(Some(&url), cx).unwrap_or_default()); + inner = inner.with_crumb(breadcrumb::Crumb::new(label, route)); + } else { + inner = inner.with_crumb(breadcrumb::Crumb::text(label)); + } + } + + Ok(inner.render(cx).await) + } +} + +impl MenuBreadcrumb { + /// Crea un `MenuBreadcrumb` para el menú con el `machine_name` dado. + pub fn with(menu_name: impl Into) -> Self { + let mut bc = Self::new(); + bc.menu_name = Some(menu_name.into()); + bc + } + + #[builder_fn] + pub fn with_include_current(mut self, v: impl Into>) -> Self { + if let Some(v) = v.into() { + self.include_current = v; + } + self + } +} + +// Extrae la cadena raíz -> nodo activo recorriendo el active trail. +fn extract_trail(nodes: &[MenuNode]) -> Vec<&MenuNode> { + for node in nodes { + if node.in_active_trail || node.is_active { + let mut path = extract_trail(&node.children); + path.insert(0, node); + return path; + } + } + vec![] +} diff --git a/extensions/pagetop-menu/src/config.rs b/extensions/pagetop-menu/src/config.rs new file mode 100644 index 00000000..27cae08e --- /dev/null +++ b/extensions/pagetop-menu/src/config.rs @@ -0,0 +1,56 @@ +//! Configuración de `pagetop-menu`. +//! +//! Todos los valores pueden sobreescribirse en los ficheros TOML de la aplicación: +//! +//! ```toml +//! [menu] +//! default_menus = ["main", "footer", "user"] +//! ``` + +use pagetop::prelude::*; +use serde::Deserialize; + +use std::sync::LazyLock; + +// **< CONFIG_MENU >******************************************************************************** + +include_config!(CONFIG_MENU: MenuTopConfig => [ + // Menús que se crean automáticamente si no los declara ninguna extensión. + // (Nota: los valores de lista no son soportados por config-rs vía set_default; se leen del + // TOML.) +]); + +// **< MenuTopConfig >****************************************************************************** + +/// Estructura raíz para la sección `[menu]` del fichero de configuración. +#[derive(Clone, Debug, Deserialize)] +pub struct MenuTopConfig { + pub menu: Settings, +} + +// **< SETTINGS >*********************************************************************************** + +/// Acceso directo a los ajustes de `pagetop-menu` (alias de `CONFIG_MENU.menu`). +pub static SETTINGS: LazyLock = LazyLock::new(|| CONFIG_MENU.menu.clone()); + +// **< Settings >*********************************************************************************** + +/// Ajustes de la extensión `pagetop-menu`, accesibles en la sección `[menu]` del TOML. +#[derive(Clone, Debug, Deserialize)] +pub struct Settings { + /// Menús que se aseguran en BD al arrancar si ninguna extensión los declara. + #[serde(default = "default_menus")] + pub default_menus: Vec, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + default_menus: default_menus(), + } + } +} + +fn default_menus() -> Vec { + vec!["main".into(), "footer".into(), "user".into()] +} diff --git a/extensions/pagetop-menu/src/entity.rs b/extensions/pagetop-menu/src/entity.rs new file mode 100644 index 00000000..3b01e834 --- /dev/null +++ b/extensions/pagetop-menu/src/entity.rs @@ -0,0 +1,6 @@ +//! Entidades SeaORM de `pagetop-menu`. + +pub mod menu; +pub mod menu_item; +pub mod menu_item_translation; +pub mod menu_translation; diff --git a/extensions/pagetop-menu/src/entity/menu.rs b/extensions/pagetop-menu/src/entity/menu.rs new file mode 100644 index 00000000..3206e900 --- /dev/null +++ b/extensions/pagetop-menu/src/entity/menu.rs @@ -0,0 +1,37 @@ +use pagetop_seaorm::db::*; + +use chrono::NaiveDateTime; + +#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] +#[sea_orm(table_name = "menus")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub machine_name: String, + pub locked: bool, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] +pub enum Relation { + #[sea_orm(has_many = "super::menu_translation::Entity")] + MenuTranslations, + #[sea_orm(has_many = "super::menu_item::Entity")] + MenuItems, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MenuTranslations.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MenuItems.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-menu/src/entity/menu_item.rs b/extensions/pagetop-menu/src/entity/menu_item.rs new file mode 100644 index 00000000..bcdc0238 --- /dev/null +++ b/extensions/pagetop-menu/src/entity/menu_item.rs @@ -0,0 +1,47 @@ +use pagetop_seaorm::db::*; + +use chrono::NaiveDateTime; + +#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] +#[sea_orm(table_name = "menu_items")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub menu_id: i32, + pub parent_id: Option, + pub url: String, + pub weight: i32, + pub enabled: bool, + pub expanded: bool, + pub provider: String, + pub external_key: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::menu::Entity", + from = "Column::MenuId", + to = "super::menu::Column::Id", + on_delete = "Cascade" + )] + Menu, + #[sea_orm(has_many = "super::menu_item_translation::Entity")] + MenuItemTranslations, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Menu.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MenuItemTranslations.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-menu/src/entity/menu_item_translation.rs b/extensions/pagetop-menu/src/entity/menu_item_translation.rs new file mode 100644 index 00000000..e8cae28c --- /dev/null +++ b/extensions/pagetop-menu/src/entity/menu_item_translation.rs @@ -0,0 +1,30 @@ +use pagetop_seaorm::db::*; + +#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] +#[sea_orm(table_name = "menu_item_translations")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub item_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub lang: String, + pub title: String, +} + +#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::menu_item::Entity", + from = "Column::ItemId", + to = "super::menu_item::Column::Id", + on_delete = "Cascade" + )] + MenuItem, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MenuItem.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-menu/src/entity/menu_translation.rs b/extensions/pagetop-menu/src/entity/menu_translation.rs new file mode 100644 index 00000000..92b55e78 --- /dev/null +++ b/extensions/pagetop-menu/src/entity/menu_translation.rs @@ -0,0 +1,31 @@ +use pagetop_seaorm::db::*; + +#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] +#[sea_orm(table_name = "menu_translations")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub menu_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub lang: String, + pub title: String, + pub description: Option, +} + +#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::menu::Entity", + from = "Column::MenuId", + to = "super::menu::Column::Id", + on_delete = "Cascade" + )] + Menu, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Menu.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-menu/src/error.rs b/extensions/pagetop-menu/src/error.rs new file mode 100644 index 00000000..5d32b086 --- /dev/null +++ b/extensions/pagetop-menu/src/error.rs @@ -0,0 +1,13 @@ +//! Tipos de error de `pagetop-menu`. + +use thiserror::Error; + +/// Errores que puede producir `pagetop-menu`. +#[derive(Debug, Error)] +pub enum MenuError { + #[error("invalid machine name: {0}")] + InvalidName(String), + + #[error("database error: {0}")] + Database(#[from] pagetop_seaorm::db::DbErr), +} diff --git a/extensions/pagetop-menu/src/lib.rs b/extensions/pagetop-menu/src/lib.rs new file mode 100644 index 00000000..9f88ee44 --- /dev/null +++ b/extensions/pagetop-menu/src/lib.rs @@ -0,0 +1,130 @@ +/*! +
+ +

PageTop Menu

+ +

Gestión centralizada y persistente de menús para PageTop.

+ +
+ +## Guía rápida + +Declara la dependencia en tu `Cargo.toml` y reenvía a `pagetop-seaorm` el motor de base de datos +que vayas a usar: + +```toml +[features] +sqlite = ["pagetop-seaorm/sqlite"] + +[dependencies] +pagetop-menu = { version = "..." } +``` + +Añade `&pagetop_menu::Menu` a las dependencias de tu extensión, declara los menús +que necesitas y añade los componentes a tus páginas: + +```rust,no_run +use pagetop::prelude::*; +use pagetop_menu::prelude::*; + +pub struct MyApp; + +#[async_trait] +impl Extension for MyApp { + fn dependencies(&self) -> Vec { + vec![&pagetop_menu::Menu] + } + + fn actions(&self) -> Vec { + actions![ + DeclareDefaultMenuItems::new("main", home_items), + ] + } + + fn configure_router(&self, router: Router) -> Router { + router.route("/", web::get(home)) + } +} + +fn home_items(bag: &mut ItemBag) { + bag.add(NewMenuItem::new() + .with_provider("myapp") + .with_external_key("home") + .with_title("Home") + .with_url("/") + .with_weight(0)); +} + +async fn home(request: HttpRequest) -> Result { + Page::new(request) + .with_child(MenuBlock::with("main")) + .render().await +} +``` +*/ + +use pagetop::prelude::*; +use pagetop_seaorm::install_migrations; + +include_locales!(LOCALES_MENU); + +pub mod action; +pub mod component; +pub mod config; +pub mod error; +pub mod tree; + +pub(crate) mod cache; +pub(crate) mod entity; +pub(crate) mod migration; +pub(crate) mod repo; +pub(crate) mod seed; + +pub use action::{ + AlterMenuTree, DeclareDefaultMenuItems, DeclareDefaultMenus, DecorateMenuItem, ItemBag, + MenuDefs, ResolveActiveTrail, +}; +pub use repo::NewMenuItem; +pub use tree::{MenuKey, MenuNode, MenuTree, TreeOptions, build_tree}; + +/// Prelude de `pagetop-menu`. +pub mod prelude { + pub use crate::action::{ + AlterMenuTree, DeclareDefaultMenuItems, DeclareDefaultMenus, DecorateMenuItem, ItemBag, + MenuDefs, ResolveActiveTrail, + }; + pub use crate::component::{MenuBlock, MenuBreadcrumb}; + pub use crate::error::MenuError; + pub use crate::repo::NewMenuItem; + pub use crate::tree::{MenuKey, MenuNode, MenuTree, TreeOptions, build_tree}; +} + +// **< Extension >********************************************************************************** + +/// Implementa la extensión `pagetop-menu`. +pub struct Menu; + +#[async_trait] +impl Extension for Menu { + fn name(&self) -> Lc { + Lc::t("extension_name", &LOCALES_MENU) + } + + fn description(&self) -> Lc { + Lc::t("extension_description", &LOCALES_MENU) + } + + fn dependencies(&self) -> Vec { + vec![&pagetop_seaorm::SeaORM] + } + + async fn initialize(&self) { + install_migrations!( + m20260629_000001_create_menus, + m20260629_000002_create_menu_translations, + m20260629_000003_create_menu_items, + m20260629_000004_create_menu_item_translations, + ); + seed::run().await; + } +} diff --git a/extensions/pagetop-menu/src/locale/en-US/common.ftl b/extensions/pagetop-menu/src/locale/en-US/common.ftl new file mode 100644 index 00000000..b03c8282 --- /dev/null +++ b/extensions/pagetop-menu/src/locale/en-US/common.ftl @@ -0,0 +1,2 @@ +extension_name = PageTop Menu +extension_description = Centralized and persistent menu management for PageTop. diff --git a/extensions/pagetop-menu/src/locale/es-ES/common.ftl b/extensions/pagetop-menu/src/locale/es-ES/common.ftl new file mode 100644 index 00000000..81e96fa9 --- /dev/null +++ b/extensions/pagetop-menu/src/locale/es-ES/common.ftl @@ -0,0 +1,2 @@ +extension_name = PageTop Menú +extension_description = Gestión centralizada y persistente de menús para PageTop. diff --git a/extensions/pagetop-menu/src/migration.rs b/extensions/pagetop-menu/src/migration.rs new file mode 100644 index 00000000..fc0caa81 --- /dev/null +++ b/extensions/pagetop-menu/src/migration.rs @@ -0,0 +1,6 @@ +//! Migraciones de `pagetop-menu`. + +pub mod m20260629_000001_create_menus; +pub mod m20260629_000002_create_menu_translations; +pub mod m20260629_000003_create_menu_items; +pub mod m20260629_000004_create_menu_item_translations; diff --git a/extensions/pagetop-menu/src/migration/m20260629_000001_create_menus.rs b/extensions/pagetop-menu/src/migration/m20260629_000001_create_menus.rs new file mode 100644 index 00000000..4d16a703 --- /dev/null +++ b/extensions/pagetop-menu/src/migration/m20260629_000001_create_menus.rs @@ -0,0 +1,32 @@ +use pagetop_seaorm::migration::*; + +pub struct Migration; + +#[pagetop::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + table_auto(Menus::Table) + .col(pk_auto(Menus::Id)) + .col(string_len_uniq(Menus::MachineName, 64)) + .col(boolean(Menus::Locked).default(false)) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Menus::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum Menus { + Table, + Id, + MachineName, + Locked, +} diff --git a/extensions/pagetop-menu/src/migration/m20260629_000002_create_menu_translations.rs b/extensions/pagetop-menu/src/migration/m20260629_000002_create_menu_translations.rs new file mode 100644 index 00000000..9fe5f045 --- /dev/null +++ b/extensions/pagetop-menu/src/migration/m20260629_000002_create_menu_translations.rs @@ -0,0 +1,58 @@ +use pagetop_seaorm::migration::*; + +pub struct Migration; + +#[pagetop::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(MenuTranslations::Table) + .if_not_exists() + .col(integer(MenuTranslations::MenuId)) + .col(string_len(MenuTranslations::Lang, 35)) + .col(string_len(MenuTranslations::Title, 128)) + .col(text_null(MenuTranslations::Description)) + .primary_key( + Index::create() + .col(MenuTranslations::MenuId) + .col(MenuTranslations::Lang), + ) + .to_owned(), + ) + .await?; + + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_menu_translations_menu_id") + .from(MenuTranslations::Table, MenuTranslations::MenuId) + .to(Menus::Table, Menus::Id) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(MenuTranslations::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum MenuTranslations { + Table, + MenuId, + Lang, + Title, + Description, +} + +#[derive(DeriveIden)] +enum Menus { + Table, + Id, +} diff --git a/extensions/pagetop-menu/src/migration/m20260629_000003_create_menu_items.rs b/extensions/pagetop-menu/src/migration/m20260629_000003_create_menu_items.rs new file mode 100644 index 00000000..52656a44 --- /dev/null +++ b/extensions/pagetop-menu/src/migration/m20260629_000003_create_menu_items.rs @@ -0,0 +1,87 @@ +use pagetop_seaorm::migration::*; + +pub struct Migration; + +#[pagetop::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + table_auto(MenuItems::Table) + .col(pk_auto(MenuItems::Id)) + .col(integer(MenuItems::MenuId)) + .col(integer_null(MenuItems::ParentId)) + .col(string_len(MenuItems::Url, 2048)) + .col(integer(MenuItems::Weight).default(0)) + .col(boolean(MenuItems::Enabled).default(true)) + .col(boolean(MenuItems::Expanded).default(false)) + .col(string_len(MenuItems::Provider, 64).default("user")) + .col(string_len_null(MenuItems::ExternalKey, 128)) + .to_owned(), + ) + .await?; + + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_menu_items_menu_id") + .from(MenuItems::Table, MenuItems::MenuId) + .to(Menus::Table, Menus::Id) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await?; + + // Índice para listar hijos ordenados. + manager + .create_index( + Index::create() + .name("idx_menu_items_menu_parent_weight") + .table(MenuItems::Table) + .col(MenuItems::MenuId) + .col(MenuItems::ParentId) + .col(MenuItems::Weight) + .to_owned(), + ) + .await?; + + // Índice de unicidad para upserts por extensión. + manager + .create_index( + Index::create() + .name("idx_menu_items_provider_key") + .table(MenuItems::Table) + .col(MenuItems::Provider) + .col(MenuItems::ExternalKey) + .unique() + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(MenuItems::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum MenuItems { + Table, + Id, + MenuId, + ParentId, + Url, + Weight, + Enabled, + Expanded, + Provider, + ExternalKey, +} + +#[derive(DeriveIden)] +enum Menus { + Table, + Id, +} diff --git a/extensions/pagetop-menu/src/migration/m20260629_000004_create_menu_item_translations.rs b/extensions/pagetop-menu/src/migration/m20260629_000004_create_menu_item_translations.rs new file mode 100644 index 00000000..2d883ead --- /dev/null +++ b/extensions/pagetop-menu/src/migration/m20260629_000004_create_menu_item_translations.rs @@ -0,0 +1,56 @@ +use pagetop_seaorm::migration::*; + +pub struct Migration; + +#[pagetop::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(MenuItemTranslations::Table) + .if_not_exists() + .col(integer(MenuItemTranslations::ItemId)) + .col(string_len(MenuItemTranslations::Lang, 35)) + .col(string_len(MenuItemTranslations::Title, 255)) + .primary_key( + Index::create() + .col(MenuItemTranslations::ItemId) + .col(MenuItemTranslations::Lang), + ) + .to_owned(), + ) + .await?; + + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_menu_item_translations_item_id") + .from(MenuItemTranslations::Table, MenuItemTranslations::ItemId) + .to(MenuItems::Table, MenuItems::Id) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(MenuItemTranslations::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum MenuItemTranslations { + Table, + ItemId, + Lang, + Title, +} + +#[derive(DeriveIden)] +enum MenuItems { + Table, + Id, +} diff --git a/extensions/pagetop-menu/src/repo.rs b/extensions/pagetop-menu/src/repo.rs new file mode 100644 index 00000000..4db2cea5 --- /dev/null +++ b/extensions/pagetop-menu/src/repo.rs @@ -0,0 +1,309 @@ +//! Operaciones de base de datos para menús e ítems de menú. + +use chrono::Utc; + +use pagetop::locale::Locale; +use pagetop_seaorm::db::{ + ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn, +}; + +use crate::entity::{menu, menu_item, menu_item_translation, menu_translation}; +use crate::error::MenuError; +use crate::tree::MenuKey; + +// **< Tipos de entrada >*************************************************************************** + +/// Datos necesarios para crear un nuevo menú. +pub struct NewMenu { + pub machine_name: String, + /// Títulos por idioma: `(lang, title)`. Al menos uno es obligatorio. + pub titles: Vec<(String, String)>, + pub locked: bool, +} + +/// Datos de un ítem nuevo o para hacer upsert. +pub struct NewMenuItem { + pub parent_key: Option, + /// Títulos por idioma: `(lang, title)`. Al menos uno es obligatorio. + pub titles: Vec<(String, String)>, + pub url: String, + pub weight: i32, + pub enabled: bool, + pub expanded: bool, + pub provider: String, + pub external_key: Option, +} + +impl NewMenuItem { + pub fn new() -> Self { + NewMenuItem { + parent_key: None, + titles: Vec::new(), + url: String::new(), + weight: 0, + enabled: true, + expanded: false, + provider: "user".into(), + external_key: None, + } + } + + /// Añade el título en el idioma por defecto de la aplicación. + pub fn with_title(mut self, title: impl Into) -> Self { + let lang = Locale::default_langid().to_string(); + self.titles.push((lang, title.into())); + self + } + + /// Añade el título en el idioma indicado. + pub fn with_title_for(mut self, lang: impl Into, title: impl Into) -> Self { + self.titles.push((lang.into(), title.into())); + self + } + + pub fn with_url(mut self, u: impl Into) -> Self { + self.url = u.into(); + self + } + + pub fn with_weight(mut self, w: i32) -> Self { + self.weight = w; + self + } + + pub fn with_enabled(mut self, v: bool) -> Self { + self.enabled = v; + self + } + + pub fn with_expanded(mut self, v: bool) -> Self { + self.expanded = v; + self + } + + pub fn with_provider(mut self, p: impl Into) -> Self { + self.provider = p.into(); + self + } + + pub fn with_external_key(mut self, k: impl Into) -> Self { + self.external_key = Some(k.into()); + self + } + + pub fn with_parent_key(mut self, k: impl Into) -> Self { + self.parent_key = Some(k.into()); + self + } +} + +impl Default for NewMenuItem { + fn default() -> Self { + Self::new() + } +} + +// **< find_menu_model >**************************************************************************** + +/// Devuelve el modelo de BD del menú dado, o `None` si no existe. +pub async fn find_menu_model(key: &MenuKey) -> Option { + match key { + MenuKey::Id(id) => menu::Entity::find_by_id(*id).one(dbconn()).await.ok()?, + MenuKey::Name(n) => menu::Entity::find() + .filter(menu::Column::MachineName.eq(n.as_str())) + .one(dbconn()) + .await + .ok()?, + } +} + +// **< create_menu >******************************************************************************** + +/// Crea un nuevo menú en la base de datos con sus traducciones iniciales. +pub async fn create_menu(input: NewMenu) -> Result { + if input.machine_name.is_empty() + || !input + .machine_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + { + return Err(MenuError::InvalidName(input.machine_name)); + } + + let now = Utc::now().naive_utc(); + let result = menu::Entity::insert(menu::ActiveModel { + id: NotSet, + machine_name: Set(input.machine_name), + locked: Set(input.locked), + created_at: Set(now), + updated_at: Set(now), + }) + .exec_with_returning(dbconn()) + .await?; + + for (lang, title) in input.titles { + upsert_menu_translation(result.id, &lang, &title, None).await?; + } + + Ok(result) +} + +/// Crea un menú sólo si no existe ya uno con el mismo `machine_name`, y sincroniza +/// la traducción del título en el idioma por defecto. +pub async fn ensure_menu(machine_name: &str, title: &str) -> Result<(), MenuError> { + let lang = Locale::default_langid().to_string(); + + let existing = find_menu_model(&MenuKey::Name(machine_name.to_owned())).await; + + let menu_id = if let Some(m) = existing { + m.id + } else { + create_menu(NewMenu { + machine_name: machine_name.to_owned(), + titles: vec![(lang.clone(), title.to_owned())], + locked: false, + }) + .await? + .id + }; + + upsert_menu_translation(menu_id, &lang, title, None).await?; + + Ok(()) +} + +// **< upsert_item >******************************************************************************** + +/// Inserta o actualiza un ítem identificado por `(provider, external_key)`. +/// +/// Si el ítem ya existe, actualiza `url`, `weight` y `parent_id`; los campos `enabled` +/// y `expanded` modificados por el administrador no se sobreescriben. Las traducciones +/// se sincronizan para los idiomas incluidos en `input.titles`. +pub async fn upsert_item( + menu_id: i32, + provider: &str, + external_key: &str, + input: NewMenuItem, +) -> Result<(), MenuError> { + let existing = menu_item::Entity::find() + .filter(menu_item::Column::Provider.eq(provider)) + .filter(menu_item::Column::ExternalKey.eq(external_key)) + .one(dbconn()) + .await?; + + let parent_id = resolve_parent_id(menu_id, &input.parent_key).await; + let now = Utc::now().naive_utc(); + + let item_id = if let Some(row) = existing { + menu_item::ActiveModel { + id: Set(row.id), + url: Set(input.url), + weight: Set(input.weight), + parent_id: Set(parent_id), + updated_at: Set(now), + ..Default::default() + } + .update(dbconn()) + .await?; + row.id + } else { + menu_item::Entity::insert(menu_item::ActiveModel { + id: NotSet, + menu_id: Set(menu_id), + parent_id: Set(parent_id), + url: Set(input.url), + weight: Set(input.weight), + enabled: Set(input.enabled), + expanded: Set(input.expanded), + provider: Set(provider.to_owned()), + external_key: Set(Some(external_key.to_owned())), + created_at: Set(now), + updated_at: Set(now), + }) + .exec_with_returning(dbconn()) + .await? + .id + }; + + for (lang, title) in input.titles { + upsert_item_translation(item_id, &lang, &title).await?; + } + + Ok(()) +} + +// **< Funciones internas >************************************************************************* + +async fn resolve_parent_id(menu_id: i32, parent_key: &Option) -> Option { + let key = parent_key.as_deref()?; + let row = menu_item::Entity::find() + .filter(menu_item::Column::MenuId.eq(menu_id)) + .filter(menu_item::Column::ExternalKey.eq(key)) + .one(dbconn()) + .await + .ok()??; + Some(row.id) +} + +async fn upsert_menu_translation( + menu_id: i32, + lang: &str, + title: &str, + description: Option<&str>, +) -> Result<(), MenuError> { + let existing = menu_translation::Entity::find() + .filter(menu_translation::Column::MenuId.eq(menu_id)) + .filter(menu_translation::Column::Lang.eq(lang)) + .one(dbconn()) + .await?; + + if existing.is_some() { + menu_translation::ActiveModel { + menu_id: Set(menu_id), + lang: Set(lang.to_owned()), + title: Set(title.to_owned()), + description: Set(description.map(str::to_owned)), + } + .update(dbconn()) + .await?; + } else { + menu_translation::Entity::insert(menu_translation::ActiveModel { + menu_id: Set(menu_id), + lang: Set(lang.to_owned()), + title: Set(title.to_owned()), + description: Set(description.map(str::to_owned)), + }) + .exec(dbconn()) + .await?; + } + + Ok(()) +} + +async fn upsert_item_translation(item_id: i32, lang: &str, title: &str) -> Result<(), MenuError> { + let existing = menu_item_translation::Entity::find() + .filter(menu_item_translation::Column::ItemId.eq(item_id)) + .filter(menu_item_translation::Column::Lang.eq(lang)) + .one(dbconn()) + .await?; + + if existing.is_some() { + menu_item_translation::ActiveModel { + item_id: Set(item_id), + lang: Set(lang.to_owned()), + title: Set(title.to_owned()), + } + .update(dbconn()) + .await?; + } else { + menu_item_translation::Entity::insert(menu_item_translation::ActiveModel { + item_id: Set(item_id), + lang: Set(lang.to_owned()), + title: Set(title.to_owned()), + }) + .exec(dbconn()) + .await?; + } + + Ok(()) +} diff --git a/extensions/pagetop-menu/src/seed.rs b/extensions/pagetop-menu/src/seed.rs new file mode 100644 index 00000000..0ee30b5e --- /dev/null +++ b/extensions/pagetop-menu/src/seed.rs @@ -0,0 +1,60 @@ +//! Sembrado inicial de menús e ítems declarados por extensiones. +//! +//! Se invoca desde `Extension::initialize()` después de aplicar las migraciones. +//! Es idempotente: puede ejecutarse en cada arranque sin duplicar datos. + +use crate::action::{DeclareDefaultMenuItems, DeclareDefaultMenus, ItemBag, MenuDefs}; +use crate::config::SETTINGS; +use crate::repo; + +/// Ejecuta el sembrado completo: +/// +/// 1. Recoge los menús declarados por extensiones vía `DeclareDefaultMenus`. +/// 2. Asegura que los menús configurados en `menu.default_menus` también existen. +/// 3. Para cada menú conocido, recoge y aplica los ítems declarados por extensiones. +pub(crate) async fn run() { + // Paso 1: recoger declaraciones de menú. + let mut defs = MenuDefs { + entries: Vec::new(), + }; + DeclareDefaultMenus::dispatch(&mut defs); + + // Paso 2: añadir los menús por defecto de configuración si no se declararon ya. + for name in &SETTINGS.default_menus { + if !defs.entries.iter().any(|(n, _)| n == name) { + let title = capitalize(name); + defs.entries.push((name.clone(), title)); + } + } + + // Paso 3: crear cada menú que no exista y sembrar sus ítems. + for (machine_name, title) in &defs.entries { + repo::ensure_menu(machine_name, title).await.ok(); + + let Some(menu_id) = + repo::find_menu_model(&crate::tree::MenuKey::Name(machine_name.to_owned())) + .await + .map(|m| m.id) + else { + continue; + }; + + let mut bag = ItemBag { items: Vec::new() }; + DeclareDefaultMenuItems::dispatch(machine_name, &mut bag); + + for item in bag.items { + if let Some(key) = item.external_key.clone() { + let provider = item.provider.clone(); + repo::upsert_item(menu_id, &provider, &key, item).await.ok(); + } + } + } +} + +fn capitalize(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().to_string() + c.as_str(), + } +} diff --git a/extensions/pagetop-menu/src/tree.rs b/extensions/pagetop-menu/src/tree.rs new file mode 100644 index 00000000..0df23a53 --- /dev/null +++ b/extensions/pagetop-menu/src/tree.rs @@ -0,0 +1,289 @@ +//! Tipos en memoria del árbol de menús y función de construcción. + +use std::collections::HashMap; + +use pagetop::locale::{Locale, RequestLocale}; +use pagetop::prelude::*; + +use crate::cache::FlatMenu; +use crate::entity::menu_item_translation; +use crate::{action, cache, repo}; + +// **< MenuKey >************************************************************************************ + +/// Selector para localizar un menú por `id` o por `machine_name`. +pub enum MenuKey { + Id(i32), + Name(String), +} + +impl From for MenuKey { + fn from(id: i32) -> Self { + MenuKey::Id(id) + } +} + +impl From<&str> for MenuKey { + fn from(name: &str) -> Self { + MenuKey::Name(name.to_owned()) + } +} + +impl From for MenuKey { + fn from(name: String) -> Self { + MenuKey::Name(name) + } +} + +// **< URL de un ítem de menú >********************************************************************* + +/// Construye la URL de un ítem de menú a partir del texto crudo guardado en BD. +/// +/// Devuelve `None` para `` o una cadena vacía (título de sección, sin enlace). El +/// `RoutePath` devuelto está sin resolver todavía: [`try_resolve_menu_url()`] es quien decide, en +/// el momento del renderizado, si debe pasar por [`Context::route()`] o dejarse tal cual. +pub fn menu_item_url(url: &str) -> Option { + (!url.is_empty() && url != "").then(|| RoutePath::new(url.to_owned())) +} + +/// Resuelve la URL de un ítem de menú para renderizado. +/// +/// Las internas pasan por [`Context::route()`] para preservar `lang` cuando corresponda; las +/// externas ([`RoutePath::is_external()`]) se devuelven tal cual, sin tocar el idioma, porque no +/// pertenecen al espacio de rutas de la aplicación. `None` (sin enlace) se propaga tal cual. +pub fn try_resolve_menu_url(url: Option<&RoutePath>, cx: &Context) -> Option { + url.map(|path| { + if path.is_external() { + path.clone() + } else { + cx.route(path.path().to_owned()) + } + }) +} + +// **< TreeOptions >******************************************************************************** + +/// Opciones de construcción del árbol de menú. +#[derive(Clone, Debug, Default)] +pub struct TreeOptions { + /// Profundidad máxima de nodos a incluir (`None` = sin límite). + pub max_depth: Option, + /// Si `true`, incluye también los ítems con `enabled = false`. + pub include_disabled: bool, +} + +// **< MenuNode >*********************************************************************************** + +/// Nodo del árbol de menú. Sus campos son `pub` para que las acciones puedan modificarlos. +#[derive(Clone, Debug)] +pub struct MenuNode { + pub item_id: i32, + pub title: String, + /// `None` si el ítem no tiene enlace (`` o URL vacía): título de sección. + pub url: Option, + pub weight: i32, + pub depth: u8, + pub enabled: bool, + pub expanded: bool, + pub provider: String, + pub external_key: Option, + /// Atributos HTML adicionales inyectados por `DecorateMenuItem` en tiempo de render. + pub attrs: HashMap, + pub children: Vec, + /// `true` si este nodo o algún descendiente coincide con la ruta actual. + pub in_active_trail: bool, + /// `true` si este nodo coincide exactamente con la ruta actual. + pub is_active: bool, +} + +// **< MenuTree >*********************************************************************************** + +/// Árbol completo de un menú, listo para renderizar. +#[derive(Clone, Debug)] +pub struct MenuTree { + pub menu_id: i32, + pub machine_name: String, + pub title: String, + pub roots: Vec, +} + +impl MenuTree { + /// Aplica `f` recursivamente a todos los nodos del árbol (post-orden). + pub fn walk_mut(&mut self, f: &mut F) { + walk_nodes_mut(&mut self.roots, f); + } +} + +fn walk_nodes_mut(nodes: &mut [MenuNode], f: &mut F) { + for node in nodes.iter_mut() { + walk_nodes_mut(&mut node.children, f); + f(node); + } +} + +// **< build_tree >********************************************************************************* + +/// Construye el árbol del menú indicado aplicando caché, acciones y active trail. +/// +/// Devuelve `None` si el menú no existe en la base de datos. +pub async fn build_tree(key: MenuKey, cx: &Context, opts: &TreeOptions) -> Option { + let menu = repo::find_menu_model(&key).await?; + + let flat = cache::get_or_load(menu.id, &menu.machine_name).await; + + let lang = RequestLocale::from_request(cx.request()) + .langid() + .to_string(); + + let menu_title = resolve_menu_title(menu.id, &lang).await; + + let roots = build_nodes(&flat, None, 1, opts, &lang); + + let mut tree = MenuTree { + menu_id: menu.id, + machine_name: menu.machine_name.clone(), + title: menu_title, + roots, + }; + + // Acciones de alteración del árbol (filtros, reordenamientos, etc.). + action::AlterMenuTree::dispatch(&menu.machine_name, &mut tree, cx); + + // Cálculo del active trail por coincidencia de URL. + let current_path = cx.request().map(|r| r.path()).unwrap_or("/"); + compute_active_trail(&mut tree.roots, current_path); + + // Acciones de resolución de active trail para rutas paramétricas o especiales. + action::ResolveActiveTrail::dispatch(&menu.machine_name, &mut tree, cx); + + // Decoración de nodos (atributos HTML, iconos, badges...). + let name = menu.machine_name.clone(); + tree.walk_mut(&mut |node| { + action::DecorateMenuItem::dispatch(&name, node, cx); + }); + + Some(tree) +} + +// **< Funciones internas >************************************************************************* + +pub(crate) fn build_nodes( + flat: &FlatMenu, + parent_id: Option, + depth: u8, + opts: &TreeOptions, + lang: &str, +) -> Vec { + if opts.max_depth.map(|d| depth > d).unwrap_or(false) { + return vec![]; + } + + let mut nodes: Vec = flat + .items + .iter() + .filter(|m| m.parent_id == parent_id && (opts.include_disabled || m.enabled)) + .map(|m| { + let translations = flat + .translations + .get(&m.id) + .map(Vec::as_slice) + .unwrap_or(&[]); + MenuNode { + item_id: m.id, + title: resolve_item_title(translations, lang), + url: menu_item_url(&m.url), + weight: m.weight, + depth, + enabled: m.enabled, + expanded: m.expanded, + provider: m.provider.clone(), + external_key: m.external_key.clone(), + attrs: HashMap::new(), + children: build_nodes(flat, Some(m.id), depth + 1, opts, lang), + in_active_trail: false, + is_active: false, + } + }) + .collect(); + + nodes.sort_by_key(|n| n.weight); + nodes +} + +pub(crate) fn compute_active_trail(nodes: &mut [MenuNode], current_path: &str) -> bool { + let mut any_active = false; + for node in nodes.iter_mut() { + let self_active = node + .url + .as_ref() + .is_some_and(|p| !p.is_external() && p.path() == current_path); + let child_active = compute_active_trail(&mut node.children, current_path); + node.is_active = self_active; + node.in_active_trail = self_active || child_active; + if node.in_active_trail { + any_active = true; + } + } + any_active +} + +// Resuelve el título del menú desde la BD aplicando la cadena de fallback de idioma. +async fn resolve_menu_title(menu_id: i32, lang: &str) -> String { + use crate::entity::menu_translation; + use pagetop_seaorm::db::{ColumnTrait, EntityTrait, QueryFilter, dbconn}; + + let rows = menu_translation::Entity::find() + .filter(menu_translation::Column::MenuId.eq(menu_id)) + .all(dbconn()) + .await + .unwrap_or_default(); + + resolve_title_from( + rows.iter().map(|r| (r.lang.as_str(), r.title.as_str())), + lang, + ) +} + +// Resuelve el título de un ítem aplicando la cadena de fallback de idioma. +fn resolve_item_title(translations: &[menu_item_translation::Model], lang: &str) -> String { + resolve_title_from( + translations + .iter() + .map(|t| (t.lang.as_str(), t.title.as_str())), + lang, + ) +} + +// Cadena de fallback: exacto -> base del lang -> idioma por defecto -> base del defecto -> +// cualquiera. +fn resolve_title_from<'a>( + translations: impl Iterator + Clone, + lang: &str, +) -> String { + let base_lang = lang.split('-').next().unwrap_or(lang); + let default_lang = Locale::default_langid().to_string(); + let base_default = default_lang.split('-').next().unwrap_or("").to_owned(); + + let candidates = [ + lang, + base_lang, + default_lang.as_str(), + base_default.as_str(), + ]; + + for candidate in candidates { + if candidate.is_empty() { + continue; + } + if let Some((_, title)) = translations.clone().find(|(l, _)| *l == candidate) { + return title.to_owned(); + } + } + + // Cualquier traducción disponible como último recurso. + translations + .clone() + .next() + .map(|(_, t)| t.to_owned()) + .unwrap_or_default() +}