diff --git a/src/base/component/html.rs b/src/base/component/html.rs index 0937605..228bfe8 100644 --- a/src/base/component/html.rs +++ b/src/base/component/html.rs @@ -1,28 +1,76 @@ use crate::prelude::*; -/// Componente básico para renderizar directamente código HTML. -#[derive(AutoDefault)] -pub struct Html(Markup); +/// Componente básico para renderizar dinámicamente código HTML recibiendo el contexto. +/// +/// Este componente permite generar contenido HTML arbitrario, usando la macro `html!` y accediendo +/// opcionalmente al contexto de renderizado. +/// +/// # Ejemplo +/// +/// ```rust +/// use pagetop::prelude::*; +/// +/// let component = Html::with(|_| { +/// html! { +/// div class="example" { +/// p { "Hello from PageTop." } +/// } +/// } +/// }); +/// ``` +/// +/// Para renderizar contenido que dependa del contexto, se puede acceder a él dentro del *closure*: +/// +/// ```rust +/// use pagetop::prelude::*; +/// +/// let component = Html::with(|cx| { +/// let user = cx.get_param::("username").unwrap_or(String::from("visitor")); +/// html! { +/// h1 { "Hello, " (user) } +/// } +/// }); +/// ``` +pub struct Html(Box Markup + Send + Sync>); +impl Default for Html { + fn default() -> Self { + Html::with(|_| html! {}) + } +} impl ComponentTrait for Html { fn new() -> Self { Html::default() } - fn prepare_component(&self, _cx: &mut Context) -> PrepareMarkup { - PrepareMarkup::With(html! { (self.0) }) + fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { + PrepareMarkup::With((self.0)(cx)) } } impl Html { - /// Crear una instancia con el código HTML del argumento. - pub fn with(html: Markup) -> Self { - Html(html) + /// Crea una instancia que generará el `Markup`, con acceso opcional al contexto. + /// + /// El método [`prepare_component`](crate::core::component::ComponentTrait::prepare_component) + /// delega el renderizado en la función proporcionada, que recibe una referencia mutable + /// al contexto de renderizado ([`Context`]). + pub fn with(f: F) -> Self + where + F: Fn(&mut Context) -> Markup + Send + Sync + 'static, + { + Html(Box::new(f)) } - /// Modifica el código HTML de la instancia con el nuevo código del argumento. - pub fn alter_html(&mut self, html: Markup) -> &mut Self { - self.0 = html; + /// Sustituye la función que genera el `Markup`. + /// + /// Permite a otras extensiones modificar la función de renderizado que se ejecutará cuando + /// [`prepare_component`](crate::core::component::ComponentTrait::prepare_component) invoque + /// esta instancia. La nueva función también recibe una referencia al contexto ([`Context`]). + pub fn alter_html(&mut self, f: F) -> &mut Self + where + F: Fn(&mut Context) -> Markup + Send + Sync + 'static, + { + self.0 = Box::new(f); self } } diff --git a/src/base/extension/welcome.rs b/src/base/extension/welcome.rs index f0664d2..d8b1259 100644 --- a/src/base/extension/welcome.rs +++ b/src/base/extension/welcome.rs @@ -24,11 +24,11 @@ impl ExtensionTrait for Welcome { async fn homepage(request: HttpRequest) -> ResultPage { let app = &global::SETTINGS.app.name; - Page::new(request) + Page::new(Some(request)) .with_title(L10n::l("welcome_page")) .with_theme("Basic") .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/welcome.css"))) - .with_component(Html::with(html! { + .with_component(Html::with(move |_| html! { div id="main-header" { header { h1 id="header-title" aria-label=(L10n::l("welcome_aria").with_arg("app", app)) { diff --git a/src/core/component/definition.rs b/src/core/component/definition.rs index 63264b7..a9a4f81 100644 --- a/src/core/component/definition.rs +++ b/src/core/component/definition.rs @@ -53,8 +53,11 @@ pub trait ComponentTrait: AnyInfo + ComponentRender + Send + Sync { /// Devuelve una representación estructurada del componente lista para renderizar. /// - /// Puede sobrescribirse para generar dinámicamente el contenido HTML. Por defecto, devuelve - /// [`PrepareMarkup::None`]. + /// Este método forma parte del ciclo de vida de los componentes y se invoca automáticamente + /// durante el proceso de construcción del documento. Puede sobrescribirse para generar + /// dinámicamente el contenido HTML con acceso al contexto de renderizado. + /// + /// Por defecto, devuelve [`PrepareMarkup::None`]. #[allow(unused_variables)] fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { PrepareMarkup::None diff --git a/src/html/context.rs b/src/html/context.rs index a1df987..456ca7a 100644 --- a/src/html/context.rs +++ b/src/html/context.rs @@ -107,7 +107,7 @@ impl Error for ErrorParam {} /// ``` #[rustfmt::skip] pub struct Context { - request : HttpRequest, // Solicitud HTTP de origen. + request : Option, // Solicitud HTTP de origen. langid : &'static LanguageIdentifier, // Identificador de idioma. theme : ThemeRef, // Referencia al tema para renderizar. layout : &'static str, // Composición del documento para renderizar. @@ -124,7 +124,7 @@ impl Context { /// El contexto inicializa el idioma, tema y composición por defecto, sin favicon ni recursos /// cargados. #[rustfmt::skip] - pub fn new(request: HttpRequest) -> Self { + pub fn new(request: Option) -> Self { Context { request, langid : &DEFAULT_LANGID, @@ -197,9 +197,9 @@ impl Context { // Context GETTERS ***************************************************************************** - /// Devuelve la solicitud HTTP asociada al documento. - pub fn request(&self) -> &HttpRequest { - &self.request + /// Devuelve una referencia a la solicitud HTTP asociada, si existe. + pub fn request(&self) -> Option<&HttpRequest> { + self.request.as_ref() } /// Devuelve el identificador de idioma asociado al documento. diff --git a/src/response/page.rs b/src/response/page.rs index b252848..0964509 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -30,9 +30,12 @@ pub struct Page { } impl Page { - /// Crea una nueva instancia de página con contexto basado en la petición HTTP. + /// Crea una nueva instancia de página. + /// + /// Si se proporciona la solicitud HTTP, se guardará en el contexto de renderizado de la página + /// para poder ser recuperada por los componentes si es necesario. #[rustfmt::skip] - pub fn new(request: HttpRequest) -> Self { + pub fn new(request: Option) -> Self { Page { title : OptionTranslated::default(), description : OptionTranslated::default(), @@ -165,7 +168,7 @@ impl Page { } /// Devuelve la solicitud HTTP asociada. - pub fn request(&self) -> &HttpRequest { + pub fn request(&self) -> Option<&HttpRequest> { self.context.request() } diff --git a/src/response/page/error.rs b/src/response/page/error.rs index 3d952c6..3a2511c 100644 --- a/src/response/page/error.rs +++ b/src/response/page/error.rs @@ -28,12 +28,12 @@ impl fmt::Display for ErrorPage { ErrorPage::BadRequest(_) => write!(f, "Bad Client Data"), // Error 403. ErrorPage::AccessDenied(request) => { - let mut error_page = Page::new(request.clone()); + let mut error_page = Page::new(Some(request.clone())); let error403 = error_page.theme().error403(&mut error_page); if let Ok(page) = error_page .with_title(L10n::n("Error FORBIDDEN")) .with_layout("error") - .with_component(Html::with(error403)) + .with_component(Html::with(move |_| error403.clone())) .render() { write!(f, "{}", page.into_string()) @@ -43,12 +43,12 @@ impl fmt::Display for ErrorPage { } // Error 404. ErrorPage::NotFound(request) => { - let mut error_page = Page::new(request.clone()); + let mut error_page = Page::new(Some(request.clone())); let error404 = error_page.theme().error404(&mut error_page); if let Ok(page) = error_page .with_title(L10n::n("Error RESOURCE NOT FOUND")) .with_layout("error") - .with_component(Html::with(error404)) + .with_component(Html::with(move |_| error404.clone())) .render() { write!(f, "{}", page.into_string()) diff --git a/tests/component_html.rs b/tests/component_html.rs new file mode 100644 index 0000000..978248f --- /dev/null +++ b/tests/component_html.rs @@ -0,0 +1,74 @@ +use pagetop::prelude::*; + +#[pagetop::test] +async fn component_html_renders_static_markup() { + let component = Html::with(|_| { + html! { + p { "Test" } + } + }); + + let markup = component + .prepare_component(&mut Context::new(None)) + .render(); + + assert_eq!(markup.0, "

Test

"); +} + +#[pagetop::test] +async fn component_html_renders_using_context_param() { + let mut cx = Context::new(None).with_param("username", String::from("Alice")); + + let component = Html::with(|cx| { + let name = cx.get_param::("username").unwrap_or_default(); + html! { + span { (name) } + } + }); + + let markup = component.prepare_component(&mut cx).render(); + + assert_eq!(markup.0, "Alice"); +} + +#[pagetop::test] +async fn component_html_allows_replacing_render_function() { + let mut component = Html::with(|_| html! { div { "Original" } }); + + component.alter_html(|_| html! { div { "Modified" } }); + + let markup = component + .prepare_component(&mut Context::new(None)) + .render(); + + assert_eq!(markup.0, "
Modified
"); +} + +#[pagetop::test] +async fn component_html_default_renders_empty_markup() { + let component = Html::default(); + + let markup = component + .prepare_component(&mut Context::new(None)) + .render(); + + assert_eq!(markup.0, ""); +} + +#[pagetop::test] +async fn component_html_can_access_http_method() { + let req = service::test::TestRequest::with_uri("/").to_http_request(); + let mut cx = Context::new(Some(req)); + + let component = Html::with(|cx| { + let method = cx + .request() + .map(|r| r.method().to_string()) + .unwrap_or_default(); + html! { span { (method) } } + }); + + let markup = component.prepare_component(&mut cx).render(); + + assert_eq!(markup.0, "GET"); +}