From 9db1d4bab0fa0adbf8ef80a76ab62c503355c05c Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 26 Jul 2026 17:55:25 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(htmx):=20A=C3=B1ade=20soporte=20OO?= =?UTF-8?q?B=20a=20HtmxResponse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuevo método `oob()` para incluir fragmentos adicionales marcados con `hx-swap-oob` en el cuerpo de la respuesta, sin necesidad de construir el envoltorio a mano ni depender del `hx-target` de la petición. De paso, `set_header()` usa ya las constantes de `hx::response` en vez de literales repetidos, y se revisan comentarios. --- extensions/pagetop-htmx/src/response.rs | 87 ++++++++++++++++++----- extensions/pagetop-htmx/tests/response.rs | 46 +++++++++++- 2 files changed, 113 insertions(+), 20 deletions(-) diff --git a/extensions/pagetop-htmx/src/response.rs b/extensions/pagetop-htmx/src/response.rs index 0712ae20..cf512633 100644 --- a/extensions/pagetop-htmx/src/response.rs +++ b/extensions/pagetop-htmx/src/response.rs @@ -2,6 +2,8 @@ use pagetop::prelude::*; +use crate::hx; + // **< HtmxResponse >******************************************************************************* /// Generador de respuestas HTML parciales con cabeceras HTMX. @@ -18,7 +20,7 @@ use pagetop::prelude::*; /// use pagetop::prelude::*; /// use pagetop_htmx::prelude::*; /// -/// async fn add_item(request: HttpRequest) -> impl IntoResponse { +/// async fn add_item() -> impl IntoResponse { /// let new_item = html! { li #item-42 { "New item" } }; /// /// HtmxResponse::new(new_item) @@ -48,6 +50,21 @@ use pagetop::prelude::*; /// - [`HtmxResponse::new(markup)`](Self::new), con el fragmento HTML. /// - [`HtmxResponse::empty()`](Self::empty), sin cuerpo, sólo cabeceras. /// +/// # Sustituciones fuera de banda (*Out-of-band*) +/// +/// Sirve para cuando una misma acción tiene que actualizar dos partes del DOM que no están una +/// dentro de la otra, así que no caben en el mismo [`hx::TARGET`]. Por ejemplo, al borrar una fila +/// de una tabla paginada donde la respuesta renderiza de nuevo la tabla hacia su destino habitual, +/// pero un contador en la cabecera de la página vive fuera de ese contenedor. Si nadie le avisa, se +/// queda mostrando el valor antiguo aunque la tabla ya esté actualizada. +/// +/// [`oob()`](Self::oob) añade a la respuesta un fragmento adicional marcado con [`hx::SWAP_OOB`], +/// que HTMX localiza por su propio `id` en cualquier parte del documento y sustituye aparte, sin +/// depender del [`hx::TARGET`] de la petición. Así, una sola petición actualiza a la vez la tabla y +/// el contador, en lugar de forzar una segunda petición aparte sólo para el contador, o de meter el +/// contador dentro del mismo contenedor que la tabla únicamente para que quede sincronizado (ver +/// ejemplo de [`oob()`](Self::oob)). +/// /// # Cabeceras disponibles /// /// Los nombres de cabecera como constantes están en [`crate::hx::response`]. @@ -86,6 +103,36 @@ impl HtmxResponse { Self::new(html! {}) } + /// Añade contenido al cuerpo de la respuesta para una sustitución fuera de banda + /// (`hx-swap-oob`), además del fragmento principal. + /// + /// El propio `markup` debe llevar su `id` y el atributo [`hx::SWAP_OOB`] puestos en su elemento + /// raíz, típicamente vía `PropsOp::set(hx::SWAP_OOB, "true")` en los `Props` del componente que + /// se está actualizando fuera de banda, igual que ya hacen [`hx::TARGET`] o [`hx::SWAP`] en + /// cualquier otro atributo. Este método sólo concatena ese contenido al cuerpo de la respuesta; + /// no construye ningún envoltorio ni comprueba su contenido, porque HTMX localiza el elemento a + /// sustituir por su `id` en el DOM actual, no por la etiqueta que use aquí (envolverlo forzaría + /// una etiqueta que podría no coincidir con la del elemento real). + /// + /// Se puede llamar varias veces para acumular varios fragmentos; el orden no importa, porque + /// HTMX los localiza por separado en todo el cuerpo de la respuesta. + /// + /// ```rust,no_run + /// use pagetop::prelude::*; + /// use pagetop_htmx::prelude::*; + /// + /// # fn build_response(table: Markup, roles_count: i64) -> HtmxResponse { + /// let counter = html! { + /// span #roles-count hx-swap-oob="true" { (roles_count) } + /// }; + /// HtmxResponse::new(table).oob(counter) + /// # } + /// ``` + pub fn oob(mut self, markup: Markup) -> Self { + self.markup = html! { (self.markup) (markup) }; + self + } + // **< HtmxResponse BUILDER >******************************************************************* /// Hace que HTMX realice una navegación AJAX a la URL indicada sin recargar la página. @@ -106,17 +153,17 @@ impl HtmxResponse { /// # } /// ``` pub fn location(self, url: impl Into) -> Self { - self.set_header(b"hx-location", url.into().to_string()) + self.set_header(hx::response::LOCATION.as_bytes(), url.into().to_string()) } /// Hace que HTMX realice una navegación AJAX personalizada, con un objeto JSON de configuración /// en lugar de una URL simple. /// - /// Acepta un objeto JSON con las claves `path`, `target`, `swap`, `select` y `values` (ver la - /// [documentación de HTMX](https://htmx.org/reference/#response_headers) para el detalle de - /// cada una). Al no ser una URL, no admite `Context::route()`: si `path` necesita el parámetro - /// `lang`, hay que componerlo a mano antes de construir el JSON. Para una navegación simple sin - /// estas opciones, usa [`location()`](Self::location). + /// Acepta un objeto JSON con claves como `path`, `target`, `swap`, `select` o `values`, entre + /// otras (ver la [documentación de HTMX](https://htmx.org/reference/#response_headers) para el + /// listado completo y el detalle de cada una). Al no ser una URL, no admite `Context::route()`: + /// si `path` necesita el parámetro `lang`, hay que componerlo a mano antes de construir el + /// JSON. Para una navegación simple sin estas opciones, usa [`location()`](Self::location). /// /// Si `json` no es sintácticamente válido, la cabecera se descarta y se registra un aviso; el /// resto de la respuesta no se ve afectado. Esta comprobación sólo valida la sintaxis JSON, no @@ -158,7 +205,7 @@ impl HtmxResponse { ); return self; } - self.set_header(b"hx-location", json) + self.set_header(hx::response::LOCATION.as_bytes(), json) } /// Empuja la URL indicada al historial del navegador. @@ -178,7 +225,7 @@ impl HtmxResponse { /// # } /// ``` pub fn push_url(self, url: impl Into) -> Self { - self.set_header(b"hx-push-url", url.into().to_string()) + self.set_header(hx::response::PUSH_URL.as_bytes(), url.into().to_string()) } /// Reemplaza la URL actual en el historial sin añadir una nueva entrada. @@ -187,7 +234,7 @@ impl HtmxResponse { /// [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal para /// que la URL preserve el parámetro `lang` cuando corresponda. pub fn replace_url(self, url: impl Into) -> Self { - self.set_header(b"hx-replace-url", url.into().to_string()) + self.set_header(hx::response::REPLACE_URL.as_bytes(), url.into().to_string()) } /// Provoca una redirección completa del navegador a la URL indicada. @@ -207,14 +254,14 @@ impl HtmxResponse { /// # } /// ``` pub fn redirect(self, url: impl Into) -> Self { - self.set_header(b"hx-redirect", url.into().to_string()) + self.set_header(hx::response::REDIRECT.as_bytes(), url.into().to_string()) } /// Provoca una recarga completa de la página actual. /// /// Equivale a `window.location.reload()` en JavaScript. pub fn refresh(self) -> Self { - self.set_header(b"hx-refresh", "true") + self.set_header(hx::response::REFRESH.as_bytes(), "true") } /// Anula el `hx-target` del elemento y redirige la respuesta al selector CSS indicado. @@ -222,7 +269,7 @@ impl HtmxResponse { /// Útil cuando el servidor necesita actualizar un elemento distinto al que realizó la petición, /// sin modificar el HTML del cliente. pub fn retarget(self, selector: impl Into) -> Self { - self.set_header(b"hx-retarget", selector) + self.set_header(hx::response::RETARGET.as_bytes(), selector) } /// Anula el `hx-swap` del elemento e impone la estrategia de sustitución indicada. @@ -230,13 +277,13 @@ impl HtmxResponse { /// Acepta los mismos valores que el atributo `hx-swap`, incluidos modificadores (`swap:200ms`, /// `scroll:top`, ...). Los valores tipados están en [`crate::hx::swap`]. pub fn reswap(self, strategy: impl Into) -> Self { - self.set_header(b"hx-reswap", strategy) + self.set_header(hx::response::RESWAP.as_bytes(), strategy) } /// Anula el `hx-select` del elemento y selecciona el fragmento CSS indicado de la respuesta /// para insertarlo en el objetivo. pub fn reselect(self, selector: impl Into) -> Self { - self.set_header(b"hx-reselect", selector) + self.set_header(hx::response::RESELECT.as_bytes(), selector) } /// Dispara uno o varios eventos JavaScript en el cliente al completar la respuesta. @@ -275,7 +322,7 @@ impl HtmxResponse { /// HtmxResponse::empty().trigger(json); /// ``` pub fn trigger(self, event: impl Into) -> Self { - self.set_header(b"hx-trigger", event) + self.set_header(hx::response::TRIGGER.as_bytes(), event) } /// Dispara eventos JavaScript después de que HTMX haya aplicado la respuesta al DOM y haya @@ -283,7 +330,7 @@ impl HtmxResponse { /// /// Acepta los mismos formatos que [`trigger()`](Self::trigger). pub fn trigger_after_settle(self, event: impl Into) -> Self { - self.set_header(b"hx-trigger-after-settle", event) + self.set_header(hx::response::TRIGGER_AFTER_SETTLE.as_bytes(), event) } /// Dispara eventos JavaScript después de que HTMX haya aplicado la respuesta al DOM, pero antes @@ -291,10 +338,12 @@ impl HtmxResponse { /// /// Acepta los mismos formatos que [`trigger()`](Self::trigger). pub fn trigger_after_swap(self, event: impl Into) -> Self { - self.set_header(b"hx-trigger-after-swap", event) + self.set_header(hx::response::TRIGGER_AFTER_SWAP.as_bytes(), event) } - // Inserta o reemplaza una cabecera. Los nombres deben ser bytes ASCII en minúsculas. + // Inserta o reemplaza una cabecera. `HeaderName::from_bytes()` normaliza mayúsculas por su + // cuenta, así que `name` admite cualquier combinación (aquí siempre llega tal cual las + // constantes de `hx::response`). fn set_header(mut self, name: &[u8], value: impl Into) -> Self { let value = value.into(); if let (Ok(n), Ok(v)) = ( diff --git a/extensions/pagetop-htmx/tests/response.rs b/extensions/pagetop-htmx/tests/response.rs index 5a75d70e..278f52c1 100644 --- a/extensions/pagetop-htmx/tests/response.rs +++ b/extensions/pagetop-htmx/tests/response.rs @@ -11,7 +11,7 @@ fn header<'a>(response: &'a web::Response, name: &str) -> Option<&'a str> { response.headers().get(name)?.to_str().ok() } -// **< HtmxResponse::new() / empty() >************************************************************** +// **< HtmxResponse::new() / empty() / oob() >****************************************************** #[pagetop::test] async fn new_renders_the_given_markup_with_an_html_content_type() { @@ -39,6 +39,50 @@ async fn empty_has_no_body_but_keeps_the_html_content_type() { assert_eq!(body, ""); } +#[pagetop::test] +async fn oob_appends_markup_after_the_main_body() { + let response = HtmxResponse::new(html! { li #item-42 { "New item" } }) + .oob(html! { span #item-count hx-swap-oob="true" { "1" } }) + .into_response(); + + let body = web::test::read_body_text(response).await; + assert_eq!( + body, + concat!( + r#"
  • New item
  • "#, + r#"1"#, + ) + ); +} + +#[pagetop::test] +async fn oob_can_be_called_several_times_to_accumulate_fragments() { + let response = HtmxResponse::new(html! { p { "Main" } }) + .oob(html! { span #a hx-swap-oob="true" { "A" } }) + .oob(html! { span #b hx-swap-oob="true" { "B" } }) + .into_response(); + + let body = web::test::read_body_text(response).await; + assert_eq!( + body, + concat!( + "

    Main

    ", + r#"A"#, + r#"B"#, + ) + ); +} + +#[pagetop::test] +async fn oob_works_from_an_empty_response() { + let response = HtmxResponse::empty() + .oob(html! { span #item-count hx-swap-oob="true" { "0" } }) + .into_response(); + + let body = web::test::read_body_text(response).await; + assert_eq!(body, r#"0"#); +} + // **< location() / location_json() >*************************************************************** #[pagetop::test]