diff --git a/extensions/pagetop-bootsier/src/theme/navbar/component.rs b/extensions/pagetop-bootsier/src/theme/navbar/component.rs index 3319b79e..33aa80de 100644 --- a/extensions/pagetop-bootsier/src/theme/navbar/component.rs +++ b/extensions/pagetop-bootsier/src/theme/navbar/component.rs @@ -79,10 +79,10 @@ impl Component for Navbar { } // Asegura que la barra tiene un `id` para poder asociarlo al colapso/offcanvas. - let id = cx.required_id::(self.id()); + let id = cx.required_id::(self.id(), 1); Ok(html! { - nav id=(id) class=[self.classes().get()] { + nav id=(&id) class=[self.classes().get()] { div class="container-fluid" { @match self.layout() { // Barra más sencilla: sólo contenido. @@ -95,7 +95,7 @@ impl Component for Navbar { @let id_content = util::join!(id, "-content"); (button(cx, TOGGLE_COLLAPSE, &id_content)) - div id=(id_content) class="collapse navbar-collapse" { + div id=(&id_content) class="collapse navbar-collapse" { (items) } }, @@ -112,7 +112,7 @@ impl Component for Navbar { (brand.render(cx)) (button(cx, TOGGLE_COLLAPSE, &id_content)) - div id=(id_content) class="collapse navbar-collapse" { + div id=(&id_content) class="collapse navbar-collapse" { (items) } }, @@ -123,7 +123,7 @@ impl Component for Navbar { (button(cx, TOGGLE_COLLAPSE, &id_content)) (brand.render(cx)) - div id=(id_content) class="collapse navbar-collapse" { + div id=(&id_content) class="collapse navbar-collapse" { (items) } }, diff --git a/extensions/pagetop-bootsier/src/theme/offcanvas/component.rs b/extensions/pagetop-bootsier/src/theme/offcanvas/component.rs index 93e14b27..88aad443 100644 --- a/extensions/pagetop-bootsier/src/theme/offcanvas/component.rs +++ b/extensions/pagetop-bootsier/src/theme/offcanvas/component.rs @@ -152,7 +152,7 @@ impl Offcanvas { return html! {}; } - let id = cx.required_id::(self.id()); + let id = cx.required_id::(self.id(), 1); let id_label = util::join!(id, "-label"); let id_target = util::join!("#", id); @@ -171,7 +171,7 @@ impl Offcanvas { html! { div - id=(id) + id=(&id) class=[self.classes().get()] tabindex="-1" data-bs-scroll=[body_scroll] @@ -180,7 +180,7 @@ impl Offcanvas { { div class="offcanvas-header" { @if !title.is_empty() { - h5 class="offcanvas-title" id=(id_label) { (title) } + h5 id=(&id_label) class="offcanvas-title" { (title) } } button type="button" diff --git a/src/base/component/block.rs b/src/base/component/block.rs index 014bda4e..01f10d42 100644 --- a/src/base/component/block.rs +++ b/src/base/component/block.rs @@ -36,10 +36,10 @@ impl Component for Block { return Ok(html! {}); } - let id = cx.required_id::(self.id()); + let id = cx.required_id::(self.id(), 1); Ok(html! { - div id=(id) class=[self.classes().get()] { + div id=(&id) class=[self.classes().get()] { @if let Some(title) = self.title().lookup(cx) { h2 class="block__title" { span { (title) } } } diff --git a/src/base/component/html.rs b/src/base/component/html.rs index 8ad4b2e9..9f44be6b 100644 --- a/src/base/component/html.rs +++ b/src/base/component/html.rs @@ -26,7 +26,7 @@ use std::sync::Arc; /// ```rust /// # use pagetop::prelude::*; /// let component = Html::with(|cx| { -/// let user = cx.param::("username").cloned().unwrap_or("visitor".to_string()); +/// let user = cx.param_or("username", "visitor".to_string()); /// html! { /// h1 { "Hello, " (user) } /// } diff --git a/src/core/component.rs b/src/core/component.rs index e62da4af..93421e13 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -63,7 +63,7 @@ pub use context::{AssetsOp, Context, ContextError, Contextual}; /// /// // Se instancia un componente que sólo se renderiza si `user_logged_in` es `true`. /// let mut component = SampleComponent::new().with_renderable(Some(|cx: &Context| { -/// cx.param::("user_logged_in").copied().unwrap_or(false) +/// cx.param_or_default::("user_logged_in") /// })); /// /// // Aquí simplemente se comprueba que compila y se puede invocar. diff --git a/src/core/component/context.rs b/src/core/component/context.rs index e33ada2b..e4a5c237 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -1,4 +1,4 @@ -use crate::core::component::{ChildOp, MessageLevel, StatusMessage}; +use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage}; use crate::core::theme::all::DEFAULT_THEME; use crate::core::theme::{ChildrenInRegions, DefaultRegion, RegionRef, TemplateRef, ThemeRef}; use crate::core::TypeInfo; @@ -77,7 +77,6 @@ impl std::error::Error for ContextError {} /// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo /// [`StyleSheet`] o los scripts [`JavaScript`] mediante [`AssetsOp`]. /// - Leer y mantener **parámetros dinámicos tipados** de contexto. -/// - Generar **identificadores únicos** por tipo de componente. /// /// Lo implementan, típicamente, estructuras que manejan el contexto de renderizado, como /// [`Context`](crate::core::component::Context) o [`Page`](crate::response::page::Page). @@ -94,7 +93,7 @@ impl std::error::Error for ContextError {} /// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico")))) /// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/app.css"))) /// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/app.js"))) -/// .with_param("usuario_id", 42_i32) +/// .with_param("user_id", 42_i32) /// } /// ``` pub trait Contextual: LangId { @@ -118,16 +117,17 @@ pub trait Contextual: LangId { /// Añade o modifica un parámetro dinámico del contexto. /// - /// El valor se guardará conservando el *nombre del tipo* real para mejorar los mensajes de - /// error posteriores. + /// El valor se almacena junto con el nombre de su tipo, lo que permite generar mensajes de + /// error precisos al recuperarlo con [`param`](Contextual::param) si el tipo solicitado no + /// coincide. /// - /// # Ejemplos + /// # Ejemplo /// /// ```rust /// # use pagetop::prelude::*; /// let cx = Context::new(None) - /// .with_param("usuario_id", 42_i32) - /// .with_param("titulo", "Hola".to_string()) + /// .with_param("user_id", 42_i32) + /// .with_param("title", "Hello".to_string()) /// .with_param("flags", vec!["a", "b"]); /// ``` #[builder_fn] @@ -158,49 +158,43 @@ pub trait Contextual: LangId { /// Devuelve la plantilla configurada para renderizar el documento. fn template(&self) -> TemplateRef; - /// Recupera un parámetro como [`Option`], simplificando el acceso. + /// Recupera una *referencia tipada* al parámetro solicitado. /// - /// A diferencia de [`get_param`](Context::get_param), que devuelve un [`Result`] con - /// información detallada de error, este método devuelve `None` tanto si la clave no existe como - /// si el valor guardado no coincide con el tipo solicitado. + /// Devuelve: /// - /// Resulta útil en escenarios donde sólo interesa saber si el valor existe y es del tipo - /// correcto, sin necesidad de diferenciar entre error de ausencia o de tipo. + /// - `Ok(&T)` si la clave existe y el tipo coincide. + /// - `Err(ContextError::ParamNotFound)` si la clave no existe. + /// - `Err(ContextError::ParamTypeMismatch)` si la clave existe pero el tipo no coincide. /// /// # Ejemplo /// /// ```rust /// # use pagetop::prelude::*; - /// let cx = Context::new(None).with_param("username", "Alice".to_string()); + /// let cx = Context::new(None) + /// .with_param("user_id", 42_i32) + /// .with_param("title", "Hello".to_string()); /// - /// // Devuelve Some(&String) si existe y coincide el tipo. - /// assert_eq!(cx.param::("username").map(|s| s.as_str()), Some("Alice")); + /// let id: i32 = *cx.param("user_id").unwrap(); + /// let title: &String = cx.param("title").unwrap(); /// - /// // Devuelve None si no existe o si el tipo no coincide. - /// assert!(cx.param::("username").is_none()); - /// assert!(cx.param::("missing").is_none()); - /// - /// // Acceso con valor por defecto. - /// let user = cx.param::("missing") - /// .cloned() - /// .unwrap_or_else(|| "visitor".to_string()); - /// assert_eq!(user, "visitor"); + /// // Error de tipo: + /// assert!(cx.param::("user_id").is_err()); /// ``` - fn param(&self, key: &'static str) -> Option<&T>; + fn param(&self, key: &'static str) -> Result<&T, ContextError>; /// Devuelve el parámetro clonado o el **valor por defecto del tipo** (`T::default()`). - fn param_or_default(&self, key: &'static str) -> T { - self.param::(key).cloned().unwrap_or_default() + fn param_or_default(&self, key: &'static str) -> T { + self.param::(key).ok().cloned().unwrap_or_default() } /// Devuelve el parámetro clonado o un **valor por defecto** si no existe. fn param_or(&self, key: &'static str, default: T) -> T { - self.param::(key).cloned().unwrap_or(default) + self.param::(key).ok().cloned().unwrap_or(default) } /// Devuelve el parámetro clonado o el **valor evaluado** por la función `f` si no existe. fn param_or_else T>(&self, key: &'static str, f: F) -> T { - self.param::(key).cloned().unwrap_or_else(f) + self.param::(key).ok().cloned().unwrap_or_else(f) } /// Devuelve el Favicon de los recursos del contexto. @@ -214,27 +208,17 @@ pub trait Contextual: LangId { // **< Contextual HELPERS >********************************************************************* - /// Devuelve el `id` proporcionado tal cual, o genera uno único para el tipo `T` si no se - /// proporciona ninguno. - /// - /// Si `id` es `None`, construye un identificador en la forma `-`, donde `` es el - /// nombre corto del tipo en minúsculas y `` un contador incremental interno del contexto. Es - /// útil para asignar identificadores HTML predecibles cuando el componente no recibe uno - /// explícito. - fn required_id(&self, id: Option) -> String; - - /// Acumula un [`StatusMessage`] en el contexto para notificar al visitante. - /// - /// Pueden generarse en cualquier punto del ciclo de una petición web (manejadores, renderizado, - /// lógica de negocio, etc.) que tengan acceso al contexto, y mostrarlos luego, por ejemplo, en - /// la página final devuelta al usuario. + /// Elimina un parámetro del contexto. Devuelve `true` si la clave existía y se eliminó. /// /// # Ejemplo /// - /// ```rust,ignore - /// cx.push_message(MessageLevel::Warning, L10n::l("session-not-valid")); + /// ```rust + /// # use pagetop::prelude::*; + /// let mut cx = Context::new(None).with_param("temp", 1u8); + /// assert!(cx.remove_param("temp")); + /// assert!(!cx.remove_param("temp")); // ya no existe /// ``` - fn push_message(&mut self, level: MessageLevel, text: L10n); + fn remove_param(&mut self, key: &'static str) -> bool; } /// Implementa un **contexto de renderizado** para un documento HTML. @@ -264,7 +248,7 @@ pub trait Contextual: LangId { /// // Añade un script JavaScript. /// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/main.js"))) /// // Añade un parámetro dinámico al contexto. -/// .with_param("usuario_id", 42) +/// .with_param("user_id", 42) /// } /// ``` /// @@ -272,34 +256,38 @@ pub trait Contextual: LangId { /// /// ```rust /// # use pagetop::prelude::*; +/// # #[derive(AutoDefault, Clone, Debug)] +/// # struct Menu; +/// # impl Component for Menu { +/// # fn new() -> Self { Self::default() } +/// # } /// fn use_context(cx: &mut Context) { /// // Recupera el tema seleccionado. /// let active_theme = cx.theme(); /// assert_eq!(active_theme.short_name(), "aliner"); /// /// // Recupera el parámetro a su tipo original. -/// let id: i32 = *cx.get_param::("usuario_id").unwrap(); +/// let id: i32 = *cx.param::("user_id").unwrap(); /// assert_eq!(id, 42); /// /// // Genera un identificador para un componente de tipo `Menu`. -/// struct Menu; -/// let unique_id = cx.required_id::(None); +/// let unique_id = cx.required_id::(None, 1); /// assert_eq!(unique_id, "menu-1"); // Si es el primero generado. /// } /// ``` #[rustfmt::skip] pub struct Context { - request : Option, // Petición HTTP de origen. - locale : RequestLocale, // Idioma asociado a la petición. - theme : ThemeRef, // Referencia al tema usado para renderizar. - template : TemplateRef, // Plantilla usada para renderizar. - favicon : Option, // Favicon, si se ha definido. - stylesheets: Assets, // Hojas de estilo CSS. - javascripts: Assets, // Scripts JavaScript. - regions : ChildrenInRegions, // Regiones de componentes para renderizar. + request : Option, // Petición HTTP de origen. + locale : RequestLocale, // Idioma asociado a la petición. + theme : ThemeRef, // Referencia al tema usado para renderizar. + template : TemplateRef, // Plantilla usada para renderizar. + favicon : Option, // Favicon, si se ha definido. + stylesheets: Assets, // Hojas de estilo CSS. + javascripts: Assets, // Scripts JavaScript. + regions : ChildrenInRegions, // Regiones de componentes para renderizar. params : HashMap<&'static str, (Box, &'static str)>, // Parámetros en ejecución. - id_counter : Cell, // Cell permite incrementarlo desde &self en required_id(). - messages : Vec, // Mensajes de usuario acumulados. + id_counter : Cell, // Cell permite incrementar desde &self en required_id(). + messages : Vec, // Mensajes de usuario acumulados. } impl Default for Context { @@ -366,90 +354,6 @@ impl Context { .render(self) } - // **< Context PARAMS >************************************************************************* - - /// Recupera una *referencia tipada* al parámetro solicitado. - /// - /// Devuelve: - /// - /// - `Ok(&T)` si la clave existe y el tipo coincide. - /// - `Err(ContextError::ParamNotFound)` si la clave no existe. - /// - `Err(ContextError::ParamTypeMismatch)` si la clave existe pero el tipo no coincide. - /// - /// # Ejemplos - /// - /// ```rust - /// # use pagetop::prelude::*; - /// let cx = Context::new(None) - /// .with_param("usuario_id", 42_i32) - /// .with_param("titulo", "Hola".to_string()); - /// - /// let id: &i32 = cx.get_param("usuario_id").unwrap(); - /// let titulo: &String = cx.get_param("titulo").unwrap(); - /// - /// // Error de tipo: - /// assert!(cx.get_param::("usuario_id").is_err()); - /// ``` - pub fn get_param(&self, key: &'static str) -> Result<&T, ContextError> { - let (any, type_name) = self.params.get(key).ok_or(ContextError::ParamNotFound)?; - any.downcast_ref::() - .ok_or_else(|| ContextError::ParamTypeMismatch { - key, - expected: TypeInfo::FullName.of::(), - saved: type_name, - }) - } - - /// Recupera el parámetro solicitado y lo elimina del contexto. - /// - /// Devuelve: - /// - /// - `Ok(T)` si la clave existía y el tipo coincide. - /// - `Err(ContextError::ParamNotFound)` si la clave no existe. - /// - `Err(ContextError::ParamTypeMismatch)` si el tipo no coincide. - /// - /// # Ejemplos - /// - /// ```rust - /// # use pagetop::prelude::*; - /// let mut cx = Context::new(None) - /// .with_param("contador", 7_i32) - /// .with_param("titulo", "Hola".to_string()); - /// - /// let n: i32 = cx.take_param("contador").unwrap(); - /// assert!(cx.get_param::("contador").is_err()); // ya no está - /// - /// // Error de tipo: - /// assert!(cx.take_param::("titulo").is_err()); - /// ``` - pub fn take_param(&mut self, key: &'static str) -> Result { - let (boxed, saved) = self.params.remove(key).ok_or(ContextError::ParamNotFound)?; - boxed - .downcast::() - .map(|b| *b) - .map_err(|_| ContextError::ParamTypeMismatch { - key, - expected: TypeInfo::FullName.of::(), - saved, - }) - } - - /// Elimina un parámetro del contexto. Devuelve `true` si la clave existía y se eliminó. - /// - /// Devuelve `false` en caso contrario. Usar cuando sólo interesa borrar la entrada. - /// - /// # Ejemplos - /// - /// ```rust - /// # use pagetop::prelude::*; - /// let mut cx = Context::new(None).with_param("temp", 1u8); - /// assert!(cx.remove_param("temp")); - /// assert!(!cx.remove_param("temp")); // ya no existe - /// ``` - pub fn remove_param(&mut self, key: &'static str) -> bool { - self.params.remove(key).is_some() - } - // **< Context HELPERS >************************************************************************ /// Construye una ruta aplicada al contexto actual. @@ -470,6 +374,51 @@ impl Context { route } + /// Garantiza un identificador único para un componente `C`, generándolo si no se proporciona + /// ninguno. + /// + /// Si `id` es `None`, crea un identificador usando los últimos segmentos del *path* completo + /// del tipo `C`, separados por `-` y en minúsculas, seguidos de un contador incremental interno + /// del contexto. Por ejemplo, para un componente `MyApp::ui::Menu` con `parts = 2` podría + /// devolver un identificador como `ui-menu-1` si ha sido el primero en generarse. + /// + /// Con `parts = 1` se usa el nombre corto del tipo. Si `parts` es `0` o supera el número de + /// segmentos del *path*, entonces se usará el *path* completo. + /// + /// Es útil para asignar identificadores HTML predecibles cuando el componente no recibe uno + /// explícito. + pub fn required_id(&self, id: Option, parts: usize) -> String { + if let Some(id) = id { + return id; + } + let segments: Vec<&str> = TypeInfo::FullName.of::().split("::").collect(); + let parts = if parts == 0 || parts >= segments.len() { + segments.len() + } else { + parts + }; + self.id_counter.set(self.id_counter.get() + 1); + let prefix = segments[segments.len() - parts..].join("-").to_lowercase(); + util::join!(prefix, "-", self.id_counter.get().to_string()) + } + + /// Acumula un [`StatusMessage`] en el contexto para notificar al visitante. + /// + /// Pueden generarse en cualquier punto del ciclo de una petición web (manejadores, renderizado, + /// lógica de negocio, etc.) que tengan acceso al contexto, y mostrarlos luego, por ejemplo, en + /// la página final devuelta al usuario. + /// + /// # Ejemplo + /// + /// ```rust + /// # use pagetop::prelude::*; + /// # let mut cx = Context::new(None); + /// cx.push_message(MessageLevel::Warning, L10n::n("Session is not valid")); + /// ``` + pub fn push_message(&mut self, level: MessageLevel, text: L10n) { + self.messages.push(StatusMessage::new(level, text)); + } + /// Devuelve todos los mensajes de usuario acumulados. pub fn messages(&self) -> &[StatusMessage] { &self.messages @@ -589,8 +538,14 @@ impl Contextual for Context { self.template } - fn param(&self, key: &'static str) -> Option<&T> { - self.get_param::(key).ok() + fn param(&self, key: &'static str) -> Result<&T, ContextError> { + let (any, type_name) = self.params.get(key).ok_or(ContextError::ParamNotFound)?; + any.downcast_ref::() + .ok_or_else(|| ContextError::ParamTypeMismatch { + key, + expected: TypeInfo::FullName.of::(), + saved: type_name, + }) } fn favicon(&self) -> Option<&Favicon> { @@ -607,26 +562,7 @@ impl Contextual for Context { // **< Contextual HELPERS >********************************************************************* - fn required_id(&self, id: Option) -> String { - if let Some(id) = id { - id - } else { - let prefix = TypeInfo::ShortName - .of::() - .trim() - .replace(' ', "_") - .to_lowercase(); - let prefix = if prefix.is_empty() { - "prefix".to_string() - } else { - prefix - }; - self.id_counter.set(self.id_counter.get() + 1); - util::join!(prefix, "-", self.id_counter.get().to_string()) - } - } - - fn push_message(&mut self, level: MessageLevel, text: L10n) { - self.messages.push(StatusMessage::new(level, text)); + fn remove_param(&mut self, key: &'static str) -> bool { + self.params.remove(key).is_some() } } diff --git a/src/response/page.rs b/src/response/page.rs index 3e1f1bf2..d8bd4b16 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -19,7 +19,7 @@ pub use error::ErrorPage; pub use actix_web::Result as ResultPage; use crate::base::action; -use crate::core::component::{AssetsOp, ChildOp, Context, Contextual}; +use crate::core::component::{AssetsOp, ChildOp, Context, ContextError, Contextual}; use crate::core::theme::{DefaultRegion, Region, RegionRef, TemplateRef, ThemeRef}; use crate::html::{html, Markup, DOCTYPE}; use crate::html::{Assets, Favicon, JavaScript, StyleSheet}; @@ -349,7 +349,7 @@ impl Contextual for Page { self.context.template() } - fn param(&self, key: &'static str) -> Option<&T> { + fn param(&self, key: &'static str) -> Result<&T, ContextError> { self.context.param(key) } @@ -367,11 +367,7 @@ impl Contextual for Page { // **< Contextual HELPERS >********************************************************************* - fn required_id(&self, id: Option) -> String { - self.context.required_id::(id) - } - - fn push_message(&mut self, level: crate::prelude::MessageLevel, text: L10n) { - self.context.push_message(level, text); + fn remove_param(&mut self, key: &'static str) -> bool { + self.context.remove_param(key) } }