From e7f2563967e4523fd26afc4e4a6ee0d91b1d245d Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 23 Jul 2026 07:21:27 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(htmx):=20A=C3=B1ade=20soporte=20HT?= =?UTF-8?q?MX=20a=20tablas=20ordenables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nuevo `SortDir` en `pagetop::html` para representar direcciones de orden (asc/desc) y calcular la siguiente al pulsar una cabecera. - `hx_table::sort_link()` construye el enlace de ordenación con los cuatro atributos `hx-*` fijos, reutilizable en cualquier tabla. - `HtmxResponse` usa `RoutePath` en `location`/`push_url`/`replace_url`/ `redirect` para preservar "lang"; `location_json()` se separa de `location()` para el caso de configuración JSON personalizada. - Añade el módulo `prelude` y una batería de tests para hx, hx_table, request, response y extension. --- extensions/pagetop-htmx/Cargo.toml | 1 + extensions/pagetop-htmx/README.md | 21 ++ extensions/pagetop-htmx/src/hx.rs | 48 +++-- extensions/pagetop-htmx/src/hx_table.rs | 74 +++++++ extensions/pagetop-htmx/src/lib.rs | 33 ++- extensions/pagetop-htmx/src/request.rs | 2 +- extensions/pagetop-htmx/src/response.rs | 138 ++++++++++-- extensions/pagetop-htmx/tests/extension.rs | 62 ++++++ extensions/pagetop-htmx/tests/hx.rs | 160 ++++++++++++++ extensions/pagetop-htmx/tests/hx_table.rs | 162 ++++++++++++++ extensions/pagetop-htmx/tests/request.rs | 169 +++++++++++++++ extensions/pagetop-htmx/tests/response.rs | 233 +++++++++++++++++++++ src/html.rs | 3 + src/html/sort_dir.rs | 116 ++++++++++ 14 files changed, 1176 insertions(+), 46 deletions(-) create mode 100644 extensions/pagetop-htmx/src/hx_table.rs create mode 100644 extensions/pagetop-htmx/tests/extension.rs create mode 100644 extensions/pagetop-htmx/tests/hx.rs create mode 100644 extensions/pagetop-htmx/tests/hx_table.rs create mode 100644 extensions/pagetop-htmx/tests/request.rs create mode 100644 extensions/pagetop-htmx/tests/response.rs create mode 100644 src/html/sort_dir.rs diff --git a/extensions/pagetop-htmx/Cargo.toml b/extensions/pagetop-htmx/Cargo.toml index 5a1c254f..d1159b5b 100644 --- a/extensions/pagetop-htmx/Cargo.toml +++ b/extensions/pagetop-htmx/Cargo.toml @@ -16,6 +16,7 @@ authors.workspace = true [dependencies] pagetop.workspace = true +serde_json.workspace = true [build-dependencies] pagetop-build.workspace = true diff --git a/extensions/pagetop-htmx/README.md b/extensions/pagetop-htmx/README.md index 5d7cf15f..d7578a4d 100644 --- a/extensions/pagetop-htmx/README.md +++ b/extensions/pagetop-htmx/README.md @@ -64,6 +64,27 @@ async fn homepage(request: HttpRequest) -> Result { } ``` +Cuando los valores se construyen en tiempo de ejecución o quieres que una extensión aplique estos +atributos sin que el componente dependa de HTMX, usa `Props` junto con las constantes de `hx` en +lugar de escribirlos como literales en `html!`: + +```rust +use pagetop::prelude::*; +use pagetop_htmx::prelude::*; + +async fn homepage(request: HttpRequest) -> Result { + let props = Props::new(hx::GET, "/api/hello") + .with_prop(PropsOp::set(hx::TARGET, "#result")); + + Page::new(request) + .with_child(Html::with(move |_| html! { + button (props) { "Say hello" } + div #result {} + })) + .render().await +} +``` + ## Créditos Este *crate* integra la biblioteca [HTMX 2.0.10](https://htmx.org), distribuida bajo licencia diff --git a/extensions/pagetop-htmx/src/hx.rs b/extensions/pagetop-htmx/src/hx.rs index e30c4651..2f1edc34 100644 --- a/extensions/pagetop-htmx/src/hx.rs +++ b/extensions/pagetop-htmx/src/hx.rs @@ -24,11 +24,9 @@ //! //! ```rust,no_run //! use pagetop::prelude::*; -//! use pagetop_htmx::hx; +//! use pagetop_htmx::prelude::*; //! -//! let endpoint = "/api/items"; // Calculado en tiempo de ejecución. -//! -//! let props = Props::new(hx::GET, endpoint) +//! let props = Props::new(hx::GET, "/api/items") //! .with_prop(PropsOp::set(hx::TARGET, "#list")) //! .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML)); //! @@ -45,7 +43,7 @@ //! //! ```rust,no_run //! use pagetop::prelude::*; -//! use pagetop_htmx::hx; +//! use pagetop_htmx::prelude::*; //! //! #[derive(AutoDefault, Getters)] //! pub struct MyButton { @@ -75,7 +73,7 @@ //! //! ```rust,no_run //! use pagetop::prelude::*; -//! use pagetop_htmx::hx; +//! use pagetop_htmx::prelude::*; //! //! // Evento nativo del DOM: hx-on:click="..." //! // Evento propio de HTMX: hx-on::after-swap="..." @@ -91,7 +89,7 @@ /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// let props = Props::new(hx::GET, "/api/search") /// .with_prop(PropsOp::set(hx::TARGET, "#results")); /// ``` @@ -113,7 +111,7 @@ pub const PATCH: &str = "hx-patch"; /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// // Al eliminar un elemento, reemplazarlo con respuesta vacía borra el nodo del DOM. /// let props = Props::new(hx::DELETE, "/api/item/42") /// .with_prop(PropsOp::set(hx::TARGET, "closest li")) @@ -130,7 +128,7 @@ pub const DELETE: &str = "hx-delete"; /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// let props = Props::new(hx::GET, "/api/detalles") /// .with_prop(PropsOp::set(hx::TARGET, "closest article")); /// ``` @@ -144,7 +142,7 @@ pub const TARGET: &str = "hx-target"; /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// // Reemplaza el elemento completo con una transición de 300 ms. /// let props = Props::new(hx::SWAP, "outerHTML swap:300ms"); /// // O usando la constante tipada más los modificadores: @@ -176,7 +174,7 @@ pub const SELECT_OOB: &str = "hx-select-oob"; /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// // Buscar mientras se escribe, con 400 ms de espera y sólo si el valor cambia: /// let props = Props::new(hx::GET, "/api/search") /// .with_prop(PropsOp::set(hx::TRIGGER, "keyup changed delay:400ms")) @@ -294,6 +292,11 @@ pub const PRESERVE: &str = "hx-preserve"; /// - `"sse"` - soporte Server-Sent Events. /// - `"json-enc"` - codifica la petición como JSON en lugar de form-urlencoded. /// - `"loading-states"` - gestión avanzada de estados de carga. +/// +/// `pagetop-htmx` sólo integra el *core* de HTMX: usar cualquiera de estas extensiones (ver el +/// [catálogo oficial](https://htmx.org/extensions/)) requiere añadir su script correspondiente por +/// separado, por ejemplo con [`JavaScript::defer()`](pagetop::html::JavaScript::defer) en +/// [`dependencies()`](pagetop::core::extension::Extension::dependencies). pub const EXT: &str = "hx-ext"; /// Atributos HTMX que los elementos descendientes NO heredarán de este elemento. @@ -348,7 +351,7 @@ pub const DISABLE: &str = "hx-disable"; /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// let props = Props::new(hx::on("click"), "this.classList.toggle('active')") /// .with_prop(PropsOp::set(hx::on("mouseenter"), "this.style.opacity='0.8'")); /// ``` @@ -364,7 +367,7 @@ pub fn on(event: &str) -> String { /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// let props = Props::new(hx::on_htmx("before-request"), "console.log('enviando...')") /// .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "initTooltips()")); /// ``` @@ -382,7 +385,7 @@ pub fn on_htmx(event: &str) -> String { /// /// ```rust,no_run /// use pagetop::prelude::*; -/// use pagetop_htmx::hx; +/// use pagetop_htmx::prelude::*; /// /// async fn handler(request: HttpRequest) { /// if let Some(target) = request.headers().get(hx::request::TARGET) { @@ -417,18 +420,19 @@ pub mod request { /// manualmente, aunque lo habitual es usar el constructor [`HtmxResponse`](crate::HtmxResponse). /// /// ```rust,no_run -/// use pagetop_htmx::hx; -/// use pagetop::web::http::{HeaderMap, HeaderName, HeaderValue}; +/// use pagetop::prelude::*; +/// use pagetop_htmx::prelude::*; /// -/// let mut headers = HeaderMap::new(); +/// let mut headers = web::http::HeaderMap::new(); /// headers.insert( -/// hx::response::TRIGGER.parse::().unwrap(), -/// HeaderValue::from_static("itemAdded"), +/// hx::response::TRIGGER.parse::().unwrap(), +/// web::http::HeaderValue::from_static("itemAdded"), /// ); /// ``` pub mod response { /// Redirige mediante AJAX a la URL o configuración JSON indicada. Ver - /// [`HtmxResponse::location()`](crate::HtmxResponse::location). + /// [`HtmxResponse::location()`](crate::HtmxResponse::location) y + /// [`HtmxResponse::location_json()`](crate::HtmxResponse::location_json). pub const LOCATION: &str = "HX-Location"; /// Empuja la URL indicada al historial del navegador. Ver /// [`HtmxResponse::push_url()`](crate::HtmxResponse::push_url). @@ -475,7 +479,7 @@ pub mod response { /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// // Reemplaza el elemento con una transición de 200 ms y desplaza al inicio: /// let props = Props::new(hx::SWAP, format!("{} swap:200ms scroll:top", hx::swap::OUTER_HTML)); /// ``` @@ -515,7 +519,7 @@ pub mod swap { /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// # use pagetop_htmx::hx; +/// # use pagetop_htmx::prelude::*; /// // Búsqueda progresiva: petición 400 ms después de que el usuario deje de escribir. /// let search = Props::new(hx::TRIGGER, "keyup changed delay:400ms"); /// diff --git a/extensions/pagetop-htmx/src/hx_table.rs b/extensions/pagetop-htmx/src/hx_table.rs new file mode 100644 index 00000000..b0cb1e51 --- /dev/null +++ b/extensions/pagetop-htmx/src/hx_table.rs @@ -0,0 +1,74 @@ +//! Soporte HTMX al componente [`Table`]. + +use pagetop::prelude::*; + +use crate::hx; + +// **< sort_link() >******************************************************************************** + +/// Construye un [`SortLink`](pagetop::base::component::table::SortLink) para actualizar el orden de +/// la tabla sin recargar la página. +/// +/// [`Table`] y `SortLink` no requieren HTMX. Cada extensión que quiera aplicar una navegación sin +/// recarga debe añadir sus propios atributos `hx-*` usando +/// [`SortLink::with_prop()`](pagetop::base::component::table::SortLink::with_prop). Como esos +/// cuatro atributos son siempre los mismos para cualquier cabecera ordenable (`hx-get` igual al +/// `href`, `hx-swap="outerHTML"` y `hx-push-url="true"`, y sólo `hx-target` cambia según la tabla), +/// [`sort_link()`] evita reescribirlos en cada columna de cada listado. +/// +/// El enlace resultante funciona igual con o sin HTMX: `href` es siempre la URL real del nuevo +/// estado de orden, así que navega correctamente aunque HTMX no esté disponible en el cliente. +/// +/// # Argumentos +/// +/// - `href`: URL completa hacia el nuevo estado de orden, reflejando ya el campo y la dirección +/// que resultarán de pulsar esta cabecera. Acepta cualquier tipo convertible a [`RoutePath`], +/// normalmente el resultado de [`Context::route()`](pagetop::core::component::Context::route), +/// para que el enlace preserve el parámetro `lang` cuando corresponda. +/// - `target`: selector CSS del elemento que HTMX debe reemplazar (`hx-target`), típicamente el +/// contenedor que envuelve la tabla completa. +/// - `dir`: dirección de orden vigente de esta columna, o `None` si la tabla está ordenada +/// actualmente por otra columna. Se traslada tal cual a +/// [`SortLink::with_dir()`](pagetop::base::component::table::SortLink::with_dir). +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// use pagetop_htmx::prelude::*; +/// +/// # fn build_column(cx: &Context) -> table::Column { +/// let current_field = "username"; // Estado vigente de la tabla. +/// let current_dir = html::SortDir::Asc; // Ordenada por "username" en ascendente. +/// +/// let field = "username"; // Cabecera de la propia columna "username". +/// let is_active = field == current_field; // En el ejemplo, coincide con el campo vigente. +/// let active = is_active.then_some(current_dir); // `Some` sólo si esta columna ordena ahora. +/// let next_dir = html::SortDir::next_for(active); // El siguiente clic alterna la dirección. +/// +/// // `cx` es el `Context` de la petición en curso. +/// let href = cx +/// .route("/admin/users") +/// .with_param("sort", field) +/// .with_param("dir", next_dir); +/// +/// table::Column::new(L10n::n("User")) +/// .with_sort(hx_table::sort_link(href, "#user-table-wrapper", active)) +/// # } +/// ``` +pub fn sort_link( + href: impl Into, + target: impl AsRef, + dir: impl Into>, +) -> table::SortLink { + // Se materializa como `String` propio porque el mismo valor sirve para dos llamadas: como + // `RoutePath` en `SortLink::new()` (vía `href.as_str()`) y como `CowStr` en `PropsOp::set()`. + let href = href.into().to_string(); + let target = target.as_ref().to_owned(); + table::SortLink::new(href.as_ref()) + .with_dir(dir) + .with_prop(PropsOp::set(hx::GET, href)) + .with_prop(PropsOp::set(hx::TARGET, target)) + .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML)) + .with_prop(PropsOp::set(hx::PUSH_URL, "true")) +} diff --git a/extensions/pagetop-htmx/src/lib.rs b/extensions/pagetop-htmx/src/lib.rs index d3c0c782..30c96dba 100644 --- a/extensions/pagetop-htmx/src/lib.rs +++ b/extensions/pagetop-htmx/src/lib.rs @@ -64,11 +64,35 @@ async fn homepage(request: HttpRequest) -> Result { .render().await } ``` + +Cuando los valores se construyen en tiempo de ejecución o quieres que una extensión aplique estos +atributos sin que el componente dependa de HTMX, usa `Props` junto con las constantes de `hx` en +lugar de escribirlos como literales en `html!`: + +```rust +use pagetop::prelude::*; +use pagetop_htmx::prelude::*; + +async fn homepage(request: HttpRequest) -> Result { + let props = Props::new(hx::GET, "/api/hello") + .with_prop(PropsOp::set(hx::TARGET, "#result")); + + Page::new(request) + .with_child(Html::with(move |_| html! { + button (props) { "Say hello" } + div #result {} + })) + .render().await +} +``` */ use pagetop::prelude::*; +include_locales!(LOCALES_HTMX); + pub mod hx; +pub mod hx_table; mod request; pub use request::HtmxRequestExt; @@ -76,7 +100,14 @@ pub use request::HtmxRequestExt; mod response; pub use response::HtmxResponse; -include_locales!(LOCALES_HTMX); +/// Prelude de `pagetop-htmx`. +pub mod prelude { + pub use crate::hx; + pub use crate::hx_table; + + pub use crate::request::HtmxRequestExt; + pub use crate::response::HtmxResponse; +} /// Integra HTMX 2 en cualquier aplicación PageTop. /// diff --git a/extensions/pagetop-htmx/src/request.rs b/extensions/pagetop-htmx/src/request.rs index 55dee38f..ff14e2f0 100644 --- a/extensions/pagetop-htmx/src/request.rs +++ b/extensions/pagetop-htmx/src/request.rs @@ -16,7 +16,7 @@ use pagetop::prelude::*; /// /// ```rust,no_run /// use pagetop::prelude::*; -/// use pagetop_htmx::{HtmxRequestExt, HtmxResponse}; +/// use pagetop_htmx::prelude::*; /// /// async fn list_items(request: HttpRequest) -> Response { /// if request.is_htmx() { diff --git a/extensions/pagetop-htmx/src/response.rs b/extensions/pagetop-htmx/src/response.rs index ddb256a3..b9221e92 100644 --- a/extensions/pagetop-htmx/src/response.rs +++ b/extensions/pagetop-htmx/src/response.rs @@ -17,7 +17,7 @@ use pagetop::prelude::*; /// /// ```rust,no_run /// use pagetop::prelude::*; -/// use pagetop_htmx::{HtmxResponse, hx}; +/// use pagetop_htmx::prelude::*; /// /// async fn add_item(request: HttpRequest) -> impl IntoResponse { /// let new_item = html! { li #item-42 { "New item" } }; @@ -37,7 +37,7 @@ use pagetop::prelude::*; /// /// ```rust,no_run /// use pagetop::prelude::*; -/// use pagetop_htmx::HtmxResponse; +/// use pagetop_htmx::prelude::*; /// /// async fn delete_item() -> impl IntoResponse { /// HtmxResponse::empty().redirect("/items") @@ -59,7 +59,7 @@ use pagetop::prelude::*; /// /// ```rust,no_run /// use pagetop::prelude::*; -/// use pagetop_htmx::HtmxResponse; +/// use pagetop_htmx::prelude::*; /// /// // Dos eventos sin datos: /// HtmxResponse::empty().trigger("itemAdded, listUpdated"); @@ -67,6 +67,7 @@ use pagetop::prelude::*; /// // Evento con datos en JSON: /// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42}}"#); /// ``` +#[must_use] pub struct HtmxResponse { markup: Markup, headers: web::http::HeaderMap, @@ -91,45 +92,123 @@ impl HtmxResponse { /// Hace que HTMX realice una navegación AJAX a la URL indicada sin recargar la página. /// /// A diferencia de [`redirect()`](Self::redirect), la navegación usa HTMX y actualiza sólo el - /// objetivo definido por el destino. Acepta una URL o un objeto JSON con claves `path`, - /// `target`, `swap`, `select` y `values` para personalizar la navegación: + /// objetivo definido por el destino. Para personalizar `target`, `swap`, `select` o `values`, + /// usa [`location_json()`](Self::location_json). + /// + /// Usa [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal + /// para que la URL preserve el parámetro `lang` cuando corresponda: /// /// ```rust,no_run /// use pagetop::prelude::*; - /// use pagetop_htmx::HtmxResponse; + /// use pagetop_htmx::prelude::*; /// - /// // Navegación simple: - /// HtmxResponse::empty().location("/items"); - /// - /// // Navegación con destino personalizado: - /// HtmxResponse::empty() - /// .location(r##"{"path": "/items", "target": "#content"}"##); + /// # fn build_response(cx: &Context) -> HtmxResponse { + /// HtmxResponse::empty().location(cx.route("/items")) + /// # } /// ``` - pub fn location(self, url: impl Into) -> Self { - self.set_header(b"hx-location", url) + pub fn location(self, url: impl Into) -> Self { + self.set_header(b"hx-location", 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). + /// + /// 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 + /// que las claves sean las que espera HTMX. + /// + /// ```rust,no_run + /// use pagetop::prelude::*; + /// use pagetop_htmx::prelude::*; + /// + /// HtmxResponse::empty() + /// .location_json(r##"{"path": "/items", "target": "#content"}"##); + /// ``` + /// + /// Si algún valor se calcula en tiempo de ejecución, constrúyelo con [`serde_json::json!`] en + /// lugar de interpolarlo a mano con `format!()`: evita comillas u otros caracteres sin escapar + /// que romperían la estructura del JSON. + /// + /// ```rust,no_run + /// use pagetop::prelude::*; + /// use pagetop_htmx::prelude::*; + /// + /// let item_name = "Alice's item"; // Contiene una comilla: no es seguro interpolarlo a mano. + /// + /// let json = serde_json::json!({ + /// "path": "/items", + /// "values": { "name": item_name }, + /// }) + /// .to_string(); + /// + /// HtmxResponse::empty().location_json(json); + /// ``` + pub fn location_json(self, json: impl Into) -> Self { + let json = json.into(); + if let Err(error) = serde_json::from_str::(&json) { + trace::warn!( + json = %json, + %error, + "HtmxResponse: invalid JSON in location_json(), header discarded", + ); + return self; + } + self.set_header(b"hx-location", json) } /// Empuja la URL indicada al historial del navegador. /// /// El usuario podrá navegar hacia atrás hasta esa URL. Usar `"false"` para desactivar el empuje /// aunque esté habilitado por el atributo `hx-push-url` del elemento. - pub fn push_url(self, url: impl Into) -> Self { - self.set_header(b"hx-push-url", url) + /// + /// Usa [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal + /// para que la URL preserve el parámetro `lang` cuando corresponda: + /// + /// ```rust,no_run + /// use pagetop::prelude::*; + /// use pagetop_htmx::prelude::*; + /// + /// # fn build_response(cx: &Context) -> HtmxResponse { + /// HtmxResponse::empty().push_url(cx.route("/items")) + /// # } + /// ``` + pub fn push_url(self, url: impl Into) -> Self { + self.set_header(b"hx-push-url", url.into().to_string()) } /// Reemplaza la URL actual en el historial sin añadir una nueva entrada. /// - /// Usar `"false"` para desactivar el reemplazo. - pub fn replace_url(self, url: impl Into) -> Self { - self.set_header(b"hx-replace-url", url) + /// Usar `"false"` para desactivar el reemplazo. Usa + /// [`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()) } /// Provoca una redirección completa del navegador a la URL indicada. /// /// A diferencia de [`location()`](Self::location), esta redirección recarga la página por /// completo, como un `window.location.href = url` en JavaScript. - pub fn redirect(self, url: impl Into) -> Self { - self.set_header(b"hx-redirect", url) + /// + /// Usa [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal + /// para que la URL preserve el parámetro `lang` cuando corresponda: + /// + /// ```rust,no_run + /// use pagetop::prelude::*; + /// use pagetop_htmx::prelude::*; + /// + /// # fn build_response(cx: &Context) -> HtmxResponse { + /// HtmxResponse::empty().redirect(cx.route("/items")) + /// # } + /// ``` + pub fn redirect(self, url: impl Into) -> Self { + self.set_header(b"hx-redirect", url.into().to_string()) } /// Provoca una recarga completa de la página actual. @@ -170,7 +249,7 @@ impl HtmxResponse { /// /// ```rust,no_run /// use pagetop::prelude::*; - /// use pagetop_htmx::HtmxResponse; + /// use pagetop_htmx::prelude::*; /// /// // Evento simple: /// HtmxResponse::empty().trigger("itemAdded"); @@ -181,6 +260,21 @@ impl HtmxResponse { /// // Evento con datos en JSON: /// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42, "name": "Example"}}"#); /// ``` + /// + /// Si el dato del evento se calcula en tiempo de ejecución, constrúyelo con + /// [`serde_json::json!`] en lugar de interpolarlo a mano con `format!()`, para evitar comillas + /// u otros caracteres sin escapar que romperían la estructura del JSON: + /// + /// ```rust,no_run + /// use pagetop::prelude::*; + /// use pagetop_htmx::prelude::*; + /// + /// let item_name = "Alice's item"; // Contiene una comilla: no es seguro interpolarlo a mano. + /// + /// let json = serde_json::json!({ "itemAdded": { "name": item_name } }).to_string(); + /// + /// HtmxResponse::empty().trigger(json); + /// ``` pub fn trigger(self, event: impl Into) -> Self { self.set_header(b"hx-trigger", event) } diff --git a/extensions/pagetop-htmx/tests/extension.rs b/extensions/pagetop-htmx/tests/extension.rs new file mode 100644 index 00000000..c0452ada --- /dev/null +++ b/extensions/pagetop-htmx/tests/extension.rs @@ -0,0 +1,62 @@ +use pagetop::prelude::*; +use pagetop_htmx::Htmx; + +struct TestApp; + +#[async_trait] +impl Extension for TestApp { + fn dependencies(&self) -> Vec { + vec![&Htmx] + } + + fn configure_router(&self, router: Router) -> Router { + router.route("/page", web::get(render_page)) + } +} + +async fn render_page(request: HttpRequest) -> Result { + Page::new(request) + .with_child(Html::with(|_| html! { p { "hello" } })) + .render() + .await +} + +// All tests in this file share the same root extension (`TestApp`), since `EXTENSIONS` is a global +// `OnceLock` initialized only once per test binary (see `core/extension/all.rs`). + +// **< Static assets >****************************************************************************** + +#[pagetop::test] +async fn htmx_script_is_served_at_the_expected_static_path() { + let app = web::test::init_router(Application::prepare(&TestApp).await.test()); + + let req = web::test::TestRequest::get() + .uri("/htmx/js/htmx.min.js") + .to_request(); + let resp = web::test::send_request(&app, req).await; + + assert_eq!(resp.status(), web::http::StatusCode::OK); + + let body = web::test::read_body_text(resp).await; + assert!(!body.is_empty()); + assert!(body.contains("htmx")); +} + +// **< Automatic script injection (BeforeRenderBody) >*********************************************** + +#[pagetop::test] +async fn rendered_pages_automatically_include_the_pinned_htmx_script_tag() { + let app = web::test::init_router(Application::prepare(&TestApp).await.test()); + + let req = web::test::TestRequest::get().uri("/page").to_request(); + let resp = web::test::send_request(&app, req).await; + + assert_eq!(resp.status(), web::http::StatusCode::OK); + + let body = web::test::read_body_text(resp).await; + // The version must stay in sync with the bundled `assets/js/htmx.min.js`; a mismatch here + // would mean the browser caches a stale script under a version tag that no longer matches it. + assert!(body.contains(r#"src="/htmx/js/htmx.min.js?v=2.0.10""#)); + assert!(body.contains("defer")); + assert!(body.contains("

hello

")); +} diff --git a/extensions/pagetop-htmx/tests/hx.rs b/extensions/pagetop-htmx/tests/hx.rs new file mode 100644 index 00000000..c2a49fcd --- /dev/null +++ b/extensions/pagetop-htmx/tests/hx.rs @@ -0,0 +1,160 @@ +use pagetop_htmx::prelude::*; + +// **< HTTP Methods >******************************************************************************* + +#[pagetop::test] +async fn http_method_constants_match_the_htmx_attribute_names() { + assert_eq!(hx::GET, "hx-get"); + assert_eq!(hx::POST, "hx-post"); + assert_eq!(hx::PUT, "hx-put"); + assert_eq!(hx::PATCH, "hx-patch"); + assert_eq!(hx::DELETE, "hx-delete"); +} + +// **< Target and Swap >**************************************************************************** + +#[pagetop::test] +async fn target_and_swap_constants_match_the_htmx_attribute_names() { + assert_eq!(hx::TARGET, "hx-target"); + assert_eq!(hx::SWAP, "hx-swap"); + assert_eq!(hx::SWAP_OOB, "hx-swap-oob"); + assert_eq!(hx::SELECT, "hx-select"); + assert_eq!(hx::SELECT_OOB, "hx-select-oob"); +} + +// **< Trigger >************************************************************************************ + +#[pagetop::test] +async fn trigger_related_constants_match_the_htmx_attribute_names() { + assert_eq!(hx::TRIGGER, "hx-trigger"); + assert_eq!(hx::BOOST, "hx-boost"); + assert_eq!(hx::PUSH_URL, "hx-push-url"); + assert_eq!(hx::REPLACE_URL, "hx-replace-url"); + assert_eq!(hx::SYNC, "hx-sync"); +} + +// **< Request Data >******************************************************************************* + +#[pagetop::test] +async fn request_data_constants_match_the_htmx_attribute_names() { + assert_eq!(hx::INCLUDE, "hx-include"); + assert_eq!(hx::PARAMS, "hx-params"); + assert_eq!(hx::VALS, "hx-vals"); + assert_eq!(hx::HEADERS, "hx-headers"); + assert_eq!(hx::ENCODING, "hx-encoding"); +} + +// **< Element Behavior >*************************************************************************** + +#[pagetop::test] +async fn element_behavior_constants_match_the_htmx_attribute_names() { + assert_eq!(hx::INDICATOR, "hx-indicator"); + assert_eq!(hx::DISABLED_ELT, "hx-disabled-elt"); + assert_eq!(hx::CONFIRM, "hx-confirm"); + assert_eq!(hx::PROMPT, "hx-prompt"); + assert_eq!(hx::VALIDATE, "hx-validate"); + assert_eq!(hx::PRESERVE, "hx-preserve"); +} + +// **< Config and Extensions >********************************************************************** + +#[pagetop::test] +async fn config_and_extension_constants_match_the_htmx_attribute_names() { + assert_eq!(hx::EXT, "hx-ext"); + assert_eq!(hx::DISINHERIT, "hx-disinherit"); + assert_eq!(hx::INHERIT, "hx-inherit"); + assert_eq!(hx::REQUEST, "hx-request"); + assert_eq!(hx::HISTORY, "hx-history"); + assert_eq!(hx::HISTORY_ELT, "hx-history-elt"); + assert_eq!(hx::DISABLE, "hx-disable"); +} + +// **< Inline Events (hx-on) >********************************************************************** + +#[pagetop::test] +async fn on_builds_the_dom_event_attribute_name() { + assert_eq!(hx::on("click"), "hx-on:click"); + assert_eq!(hx::on("mouseenter"), "hx-on:mouseenter"); +} + +#[pagetop::test] +async fn on_htmx_builds_the_htmx_lifecycle_event_attribute_name() { + assert_eq!(hx::on_htmx("before-request"), "hx-on::before-request"); + assert_eq!(hx::on_htmx("after-swap"), "hx-on::after-swap"); +} + +#[pagetop::test] +async fn on_and_on_htmx_use_a_different_separator_for_the_same_event_name() { + // The single/double colon is the only thing that distinguishes a native DOM event from an + // HTMX lifecycle event with the same name; a typo here would silently listen to the wrong one. + let event = "after-swap"; + assert_ne!(hx::on(event), hx::on_htmx(event)); + assert_eq!(hx::on(event), "hx-on:after-swap"); + assert_eq!(hx::on_htmx(event), "hx-on::after-swap"); +} + +// **< HTMX Request Headers (hx::request) >********************************************************* + +#[pagetop::test] +async fn request_header_constants_match_the_lowercase_htmx_header_names() { + assert_eq!(hx::request::REQUEST, "hx-request"); + assert_eq!(hx::request::BOOSTED, "hx-boosted"); + assert_eq!(hx::request::CURRENT_URL, "hx-current-url"); + assert_eq!( + hx::request::HISTORY_RESTORE_REQUEST, + "hx-history-restore-request" + ); + assert_eq!(hx::request::PROMPT, "hx-prompt"); + assert_eq!(hx::request::TARGET, "hx-target"); + assert_eq!(hx::request::TRIGGER, "hx-trigger"); + assert_eq!(hx::request::TRIGGER_NAME, "hx-trigger-name"); +} + +// **< HTMX Response Headers (hx::response) >******************************************************* + +#[pagetop::test] +async fn response_header_constants_match_the_capitalized_htmx_header_names() { + // Unlike the request headers, HTMX documents the response headers in their canonical + // capitalized form (`HX-Location`, not `hx-location`); the constants mirror that on purpose. + assert_eq!(hx::response::LOCATION, "HX-Location"); + assert_eq!(hx::response::PUSH_URL, "HX-Push-Url"); + assert_eq!(hx::response::REDIRECT, "HX-Redirect"); + assert_eq!(hx::response::REFRESH, "HX-Refresh"); + assert_eq!(hx::response::REPLACE_URL, "HX-Replace-Url"); + assert_eq!(hx::response::RESWAP, "HX-Reswap"); + assert_eq!(hx::response::RETARGET, "HX-Retarget"); + assert_eq!(hx::response::RESELECT, "HX-Reselect"); + assert_eq!(hx::response::TRIGGER, "HX-Trigger"); + assert_eq!( + hx::response::TRIGGER_AFTER_SETTLE, + "HX-Trigger-After-Settle" + ); + assert_eq!(hx::response::TRIGGER_AFTER_SWAP, "HX-Trigger-After-Swap"); +} + +// **< hx-swap Values (hx::swap) >****************************************************************** + +#[pagetop::test] +async fn swap_value_constants_match_the_htmx_swap_strategies() { + assert_eq!(hx::swap::INNER_HTML, "innerHTML"); + assert_eq!(hx::swap::OUTER_HTML, "outerHTML"); + assert_eq!(hx::swap::BEFORE_BEGIN, "beforebegin"); + assert_eq!(hx::swap::AFTER_BEGIN, "afterbegin"); + assert_eq!(hx::swap::BEFORE_END, "beforeend"); + assert_eq!(hx::swap::AFTER_END, "afterend"); + assert_eq!(hx::swap::DELETE, "delete"); + assert_eq!(hx::swap::NONE, "none"); +} + +// **< hx-trigger Values (hx::trigger) >************************************************************ + +#[pagetop::test] +async fn trigger_value_constants_match_the_htmx_event_names() { + assert_eq!(hx::trigger::CLICK, "click"); + assert_eq!(hx::trigger::CHANGE, "change"); + assert_eq!(hx::trigger::SUBMIT, "submit"); + assert_eq!(hx::trigger::KEYUP, "keyup"); + assert_eq!(hx::trigger::LOAD, "load"); + assert_eq!(hx::trigger::REVEALED, "revealed"); + assert_eq!(hx::trigger::INTERSECT, "intersect"); +} diff --git a/extensions/pagetop-htmx/tests/hx_table.rs b/extensions/pagetop-htmx/tests/hx_table.rs new file mode 100644 index 00000000..08a67fa7 --- /dev/null +++ b/extensions/pagetop-htmx/tests/hx_table.rs @@ -0,0 +1,162 @@ +use pagetop::prelude::*; +use pagetop_htmx::prelude::*; + +// Forces an effective language different from the default negotiated one (en-US, with no `?lang` in +// the request), so that `Context::route()` decides to propagate `?lang=...` in local routes. +fn cx_with_lang(lang: &str) -> Context { + Context::new(None).with_langid(&Locale::resolve(lang)) +} + +async fn render_column(column: table::Column) -> String { + let mut table = Table::new().with_column(column); + table.render(&mut Context::default()).await.into_string() +} + +// **< sort_link() - htmx attributes >************************************************************** + +#[pagetop::test] +async fn sort_link_sets_the_four_fixed_htmx_attributes() { + let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link( + "/admin/users", + "#user-table", + None, + )); + + let html = render_column(column).await; + + assert!(html.contains(r#"hx-get="/admin/users""#)); + assert!(html.contains(r##"hx-target="#user-table""##)); + assert!(html.contains(r#"hx-swap="outerHTML""#)); + assert!(html.contains(r#"hx-push-url="true""#)); +} + +#[pagetop::test] +async fn sort_link_href_matches_the_hx_get_value() { + // The link must work with or without HTMX: `href` is the real destination, and `hx-get` must + // request that very same URL so both navigation paths land on the same state. + let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link( + "/admin/users?sort=username", + "#user-table", + None, + )); + + let html = render_column(column).await; + + assert!(html.contains(r#"href="/admin/users?sort=username""#)); + assert!(html.contains(r#"hx-get="/admin/users?sort=username""#)); +} + +#[pagetop::test] +async fn sort_link_target_is_configurable_per_table() { + let column = table::Column::new(L10n::n("Email")).with_sort(hx_table::sort_link( + "/admin/users", + "#other-wrapper", + None, + )); + + let html = render_column(column).await; + + assert!(html.contains(r##"hx-target="#other-wrapper""##)); +} + +// **< sort_link() - sort direction propagation >*************************************************** + +#[pagetop::test] +async fn sort_link_without_active_direction_marks_aria_sort_none() { + let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link( + "/admin/users", + "#user-table", + None, + )); + + let html = render_column(column).await; + + assert!(html.contains(r#"aria-sort="none""#)); +} + +#[pagetop::test] +async fn sort_link_with_active_direction_marks_aria_sort_and_css_class() { + let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link( + "/admin/users", + "#user-table", + SortDir::Desc, + )); + + let html = render_column(column).await; + + assert!(html.contains(r#"aria-sort="descending""#)); + assert!(html.contains("table-sort table-sort-desc")); +} + +// **< sort_link() - RoutePath / Context::route() integration >************************************* + +#[pagetop::test] +async fn sort_link_with_a_bare_literal_href_never_adds_lang() { + // `sort_link()` does not receive `cx`, so it cannot add `lang` on its own: passing a raw + // literal must leave both `href` and `hx-get` exactly as given. + let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link( + "/admin/users", + "#user-table", + None, + )); + + let html = render_column(column).await; + + assert!(html.contains(r#"href="/admin/users""#)); + assert!(!html.contains("lang=")); +} + +#[pagetop::test] +async fn sort_link_carries_through_a_lang_aware_href_unchanged() { + // The caller is expected to resolve `href` with `cx.route(...)` beforehand (see the type's own + // doc example); `sort_link()` must not re-encode or otherwise alter what it receives. + let cx = cx_with_lang("es-ES"); + let href = cx.route("/admin/users"); + + let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link( + href, + "#user-table", + None, + )); + + let html = render_column(column).await; + + assert!(html.contains(r#"href="/admin/users?lang=es-ES""#)); + assert!(html.contains(r#"hx-get="/admin/users?lang=es-ES""#)); +} + +#[pagetop::test] +async fn sort_link_carries_through_extra_query_params_in_order() { + let cx = cx_with_lang("es-ES"); + let href = cx + .route("/admin/users") + .with_param("sort", "username") + .with_param("dir", "desc"); + + let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link( + href, + "#user-table", + SortDir::Desc, + )); + + let html = render_column(column).await; + + // `&` is escaped to `&` because this ends up inside an HTML attribute value. + assert!(html.contains(r#"href="/admin/users?lang=es-ES&sort=username&dir=desc""#)); +} + +#[pagetop::test] +async fn sort_link_with_an_external_href_is_left_untouched() { + // `Context::route()` never adds `lang` to a URL that looks external; `sort_link()` must not + // reintroduce it either, since it only forwards whatever `RoutePath` it receives. + let cx = cx_with_lang("es-ES"); + let href = cx.route("https://example.com/export"); + + let column = + table::Column::new(L10n::n("Export")).with_sort(hx_table::sort_link(href, "#table", None)); + + let html = render_column(column).await; + + assert!(html.contains(r#"href="https://example.com/export""#)); + assert!(!html.contains("lang=")); +} diff --git a/extensions/pagetop-htmx/tests/request.rs b/extensions/pagetop-htmx/tests/request.rs new file mode 100644 index 00000000..63bd842c --- /dev/null +++ b/extensions/pagetop-htmx/tests/request.rs @@ -0,0 +1,169 @@ +use pagetop::prelude::*; +use pagetop_htmx::HtmxRequestExt; + +struct TestApp; + +#[async_trait] +impl Extension for TestApp { + fn configure_router(&self, router: Router) -> Router { + router.route("/echo", web::get(echo_request)) + } +} + +// Reports every `HtmxRequestExt` value as JSON, so a single route can back every test in this file +// without needing a dedicated handler per header. +async fn echo_request(request: HttpRequest) -> String { + serde_json::json!({ + "is_htmx": request.is_htmx(), + "is_boosted": request.is_boosted(), + "is_history_restore": request.is_history_restore(), + "current_url": request.hx_current_url(), + "target": request.hx_target(), + "trigger_id": request.hx_trigger_id(), + "trigger_name": request.hx_trigger_name(), + "prompt": request.hx_prompt(), + }) + .to_string() +} + +async fn echo(app: &Router, headers: &[(&str, &str)]) -> serde_json::Value { + let mut req = web::test::TestRequest::get().uri("/echo"); + for (name, value) in headers { + req = req.header(*name, *value); + } + let resp = web::test::send_request(app, req.to_request()).await; + let body = web::test::read_body_text(resp).await; + serde_json::from_str(&body).unwrap() +} + +// All tests in this file share the same root extension (`TestApp`), since `EXTENSIONS` is a global +// `OnceLock` initialized only once per test binary (see `core/extension/all.rs`). + +// **< is_htmx() >********************************************************************************** + +#[pagetop::test] +async fn is_htmx_is_true_only_when_hx_request_is_exactly_true() { + let app = web::test::init_router(Application::prepare(&TestApp).await.test()); + + let with_header = echo(&app, &[("hx-request", "true")]).await; + assert_eq!(with_header["is_htmx"], true); + + let without_header = echo(&app, &[]).await; + assert_eq!(without_header["is_htmx"], false); + + // A stray/incorrect value must not be treated as a truthy HTMX request. + let wrong_value = echo(&app, &[("hx-request", "false")]).await; + assert_eq!(wrong_value["is_htmx"], false); +} + +// **< is_boosted() >******************************************************************************* + +#[pagetop::test] +async fn is_boosted_reflects_the_hx_boosted_header() { + let app = web::test::init_router(Application::prepare(&TestApp).await.test()); + + let boosted = echo(&app, &[("hx-boosted", "true")]).await; + assert_eq!(boosted["is_boosted"], true); + + let not_boosted = echo(&app, &[]).await; + assert_eq!(not_boosted["is_boosted"], false); +} + +// **< is_history_restore() >*********************************************************************** + +#[pagetop::test] +async fn is_history_restore_reflects_the_hx_history_restore_request_header() { + let app = web::test::init_router(Application::prepare(&TestApp).await.test()); + + let restoring = echo(&app, &[("hx-history-restore-request", "true")]).await; + assert_eq!(restoring["is_history_restore"], true); + + let not_restoring = echo(&app, &[]).await; + assert_eq!(not_restoring["is_history_restore"], false); +} + +// **< hx_current_url() / hx_target() / hx_trigger_id() / hx_trigger_name() / hx_prompt() >********* + +#[pagetop::test] +async fn hx_current_url_reads_the_hx_current_url_header_when_present() { + let app = web::test::init_router(Application::prepare(&TestApp).await.test()); + + let with_url = echo(&app, &[("hx-current-url", "/admin/users?page=2")]).await; + assert_eq!(with_url["current_url"], "/admin/users?page=2"); + + let without_url = echo(&app, &[]).await; + assert!(without_url["current_url"].is_null()); +} + +#[pagetop::test] +async fn hx_target_reads_the_hx_target_header_when_present() { + let app = web::test::init_router(Application::prepare(&TestApp).await.test()); + + let with_target = echo(&app, &[("hx-target", "user-table")]).await; + assert_eq!(with_target["target"], "user-table"); + + let without_target = echo(&app, &[]).await; + assert!(without_target["target"].is_null()); +} + +#[pagetop::test] +async fn hx_trigger_id_reads_the_hx_trigger_header_when_present() { + let app = web::test::init_router(Application::prepare(&TestApp).await.test()); + + let with_trigger = echo(&app, &[("hx-trigger", "save-button")]).await; + assert_eq!(with_trigger["trigger_id"], "save-button"); + + let without_trigger = echo(&app, &[]).await; + assert!(without_trigger["trigger_id"].is_null()); +} + +#[pagetop::test] +async fn hx_trigger_name_reads_the_hx_trigger_name_header_when_present() { + let app = web::test::init_router(Application::prepare(&TestApp).await.test()); + + let with_name = echo(&app, &[("hx-trigger-name", "email")]).await; + assert_eq!(with_name["trigger_name"], "email"); + + let without_name = echo(&app, &[]).await; + assert!(without_name["trigger_name"].is_null()); +} + +#[pagetop::test] +async fn hx_prompt_reads_the_hx_prompt_header_when_present() { + let app = web::test::init_router(Application::prepare(&TestApp).await.test()); + + let with_prompt = echo(&app, &[("hx-prompt", "Are you sure?")]).await; + assert_eq!(with_prompt["prompt"], "Are you sure?"); + + let without_prompt = echo(&app, &[]).await; + assert!(without_prompt["prompt"].is_null()); +} + +// **< A realistic combined request >*************************************************************** + +#[pagetop::test] +async fn a_realistic_htmx_request_reports_all_fields_consistently() { + // Simulates a table sort click: a boosted-free HTMX request triggered by a link with an `id`, + // targeting the table wrapper. + let app = web::test::init_router(Application::prepare(&TestApp).await.test()); + + let result = echo( + &app, + &[ + ("hx-request", "true"), + ("hx-target", "user-table"), + ("hx-trigger", "sort-username"), + ("hx-current-url", "/admin/users"), + ], + ) + .await; + + assert_eq!(result["is_htmx"], true); + assert_eq!(result["is_boosted"], false); + assert_eq!(result["is_history_restore"], false); + assert_eq!(result["target"], "user-table"); + assert_eq!(result["trigger_id"], "sort-username"); + assert_eq!(result["current_url"], "/admin/users"); + assert!(result["trigger_name"].is_null()); + assert!(result["prompt"].is_null()); +} diff --git a/extensions/pagetop-htmx/tests/response.rs b/extensions/pagetop-htmx/tests/response.rs new file mode 100644 index 00000000..5a75d70e --- /dev/null +++ b/extensions/pagetop-htmx/tests/response.rs @@ -0,0 +1,233 @@ +use pagetop::prelude::*; +use pagetop_htmx::prelude::*; + +// Forces an effective language different from the default negotiated one (en-US, with no `?lang` in +// the request), so that `Context::route()` decides to propagate `?lang=...` in local routes. +fn cx_with_lang(lang: &str) -> Context { + Context::new(None).with_langid(&Locale::resolve(lang)) +} + +fn header<'a>(response: &'a web::Response, name: &str) -> Option<&'a str> { + response.headers().get(name)?.to_str().ok() +} + +// **< HtmxResponse::new() / empty() >************************************************************** + +#[pagetop::test] +async fn new_renders_the_given_markup_with_an_html_content_type() { + let response = HtmxResponse::new(html! { li #item-42 { "New item" } }).into_response(); + + assert_eq!( + header(&response, "content-type"), + Some("text/html; charset=utf-8") + ); + + let body = web::test::read_body_text(response).await; + assert_eq!(body, r#"
  • New item
  • "#); +} + +#[pagetop::test] +async fn empty_has_no_body_but_keeps_the_html_content_type() { + let response = HtmxResponse::empty().into_response(); + + assert_eq!( + header(&response, "content-type"), + Some("text/html; charset=utf-8") + ); + + let body = web::test::read_body_text(response).await; + assert_eq!(body, ""); +} + +// **< location() / location_json() >*************************************************************** + +#[pagetop::test] +async fn location_sets_hx_location_from_a_route_path() { + let response = HtmxResponse::empty().location("/items").into_response(); + + assert_eq!(header(&response, "hx-location"), Some("/items")); +} + +#[pagetop::test] +async fn location_preserves_lang_when_built_from_context_route() { + let cx = cx_with_lang("es-ES"); + + let response = HtmxResponse::empty() + .location(cx.route("/items")) + .into_response(); + + assert_eq!(header(&response, "hx-location"), Some("/items?lang=es-ES")); +} + +#[pagetop::test] +async fn location_json_sets_hx_location_when_the_json_is_syntactically_valid() { + let json = r##"{"path": "/items", "target": "#content"}"##; + + let response = HtmxResponse::empty().location_json(json).into_response(); + + assert_eq!(header(&response, "hx-location"), Some(json)); +} + +#[pagetop::test] +async fn location_json_discards_the_header_when_the_json_is_malformed() { + // Missing closing brace: invalid JSON. The header must be silently dropped rather than sending + // a broken payload to the client. + let response = HtmxResponse::empty() + .location_json(r##"{"path": "/items""##) + .into_response(); + + assert_eq!(header(&response, "hx-location"), None); +} + +#[pagetop::test] +async fn location_json_only_validates_syntax_not_the_expected_keys() { + // A key HTMX does not recognize (`"tagret"` instead of `"target"`) is still valid JSON, so it + // passes this check; the mistake would only surface client-side. This documents that limit. + let json = r##"{"path": "/items", "tagret": "#content"}"##; + + let response = HtmxResponse::empty().location_json(json).into_response(); + + assert_eq!(header(&response, "hx-location"), Some(json)); +} + +// **< push_url() / replace_url() / redirect() >**************************************************** + +#[pagetop::test] +async fn push_url_sets_hx_push_url_from_a_route_path() { + let cx = cx_with_lang("es-ES"); + + let response = HtmxResponse::empty() + .push_url(cx.route("/items")) + .into_response(); + + assert_eq!(header(&response, "hx-push-url"), Some("/items?lang=es-ES")); +} + +#[pagetop::test] +async fn push_url_accepts_the_false_sentinel_to_disable_pushing() { + let response = HtmxResponse::empty().push_url("false").into_response(); + + assert_eq!(header(&response, "hx-push-url"), Some("false")); +} + +#[pagetop::test] +async fn replace_url_sets_hx_replace_url_from_a_route_path() { + let response = HtmxResponse::empty() + .replace_url("/items/42") + .into_response(); + + assert_eq!(header(&response, "hx-replace-url"), Some("/items/42")); +} + +#[pagetop::test] +async fn redirect_sets_hx_redirect_from_a_route_path() { + let cx = cx_with_lang("es-ES"); + + let response = HtmxResponse::empty() + .redirect(cx.route("/items")) + .into_response(); + + assert_eq!(header(&response, "hx-redirect"), Some("/items?lang=es-ES")); +} + +// **< refresh() / retarget() / reswap() / reselect() >********************************************* + +#[pagetop::test] +async fn refresh_sets_hx_refresh_to_true() { + let response = HtmxResponse::empty().refresh().into_response(); + + assert_eq!(header(&response, "hx-refresh"), Some("true")); +} + +#[pagetop::test] +async fn retarget_reswap_and_reselect_set_the_expected_headers() { + let response = HtmxResponse::empty() + .retarget("#message") + .reswap(hx::swap::BEFORE_END) + .reselect("#fragment") + .into_response(); + + assert_eq!(header(&response, "hx-retarget"), Some("#message")); + assert_eq!(header(&response, "hx-reswap"), Some("beforeend")); + assert_eq!(header(&response, "hx-reselect"), Some("#fragment")); +} + +// **< trigger() / trigger_after_settle() / trigger_after_swap() >********************************** + +#[pagetop::test] +async fn trigger_accepts_a_single_event_name() { + let response = HtmxResponse::empty().trigger("itemAdded").into_response(); + + assert_eq!(header(&response, "hx-trigger"), Some("itemAdded")); +} + +#[pagetop::test] +async fn trigger_accepts_multiple_comma_separated_events() { + let response = HtmxResponse::empty() + .trigger("itemAdded, listUpdated") + .into_response(); + + assert_eq!( + header(&response, "hx-trigger"), + Some("itemAdded, listUpdated") + ); +} + +#[pagetop::test] +async fn trigger_accepts_a_json_payload_with_event_data() { + let json = r#"{"itemAdded": {"id": 42, "name": "Example"}}"#; + + let response = HtmxResponse::empty().trigger(json).into_response(); + + assert_eq!(header(&response, "hx-trigger"), Some(json)); +} + +#[pagetop::test] +async fn trigger_after_settle_and_trigger_after_swap_use_their_own_headers() { + let response = HtmxResponse::empty() + .trigger_after_settle("settled") + .trigger_after_swap("swapped") + .into_response(); + + assert_eq!( + header(&response, "hx-trigger-after-settle"), + Some("settled") + ); + assert_eq!(header(&response, "hx-trigger-after-swap"), Some("swapped")); +} + +// **< Builder chaining behavior >****************************************************************** + +#[pagetop::test] +async fn chaining_several_methods_sets_all_their_headers_at_once() { + let response = HtmxResponse::new(html! { ul { li { "Item 1" } li { "Item 2" } } }) + .retarget("#list") + .reswap(hx::swap::BEFORE_END) + .push_url("/items") + .trigger("itemAdded") + .into_response(); + + assert_eq!(header(&response, "hx-retarget"), Some("#list")); + assert_eq!(header(&response, "hx-reswap"), Some("beforeend")); + assert_eq!(header(&response, "hx-push-url"), Some("/items")); + assert_eq!(header(&response, "hx-trigger"), Some("itemAdded")); +} + +#[pagetop::test] +async fn calling_the_same_method_twice_the_last_call_wins() { + let response = HtmxResponse::empty() + .trigger("first") + .trigger("second") + .into_response(); + + assert_eq!(header(&response, "hx-trigger"), Some("second")); +} + +#[pagetop::test] +async fn a_header_value_with_control_characters_is_silently_discarded() { + // `\n` is forbidden in an HTTP header value; `set_header()` must drop it rather than panicking + // or producing a malformed response. + let response = HtmxResponse::empty().retarget("foo\nbar").into_response(); + + assert_eq!(header(&response, "hx-retarget"), None); +} diff --git a/src/html.rs b/src/html.rs index a368f2ad..1f8e346e 100644 --- a/src/html.rs +++ b/src/html.rs @@ -6,6 +6,9 @@ pub use maud::{DOCTYPE, Escaper, Markup, PreEscaped, Render, display, html, html mod route_path; pub use route_path::RoutePath; +mod sort_dir; +pub use sort_dir::SortDir; + // **< HTML DOCUMENT ASSETS >*********************************************************************** mod assets; diff --git a/src/html/sort_dir.rs b/src/html/sort_dir.rs new file mode 100644 index 00000000..c6706ea9 --- /dev/null +++ b/src/html/sort_dir.rs @@ -0,0 +1,116 @@ +use crate::AutoDefault; + +/// Representa una dirección de ordenación (ascendente o descendente). +/// +/// Es un tipo definido exclusivamente para trabajar con datos ordenables. No depende de ningún +/// componente. Sirve tanto para representar el estado de un listado ordenable en la capa de +/// servicio (interpretando el valor de la *query string* con [`from_query()`](Self::from_query) y +/// serializándolo con [`as_str()`](Self::as_str)), como para calcular la dirección que debe llevar +/// un enlace de ordenación de la interfaz si se vuelve a pulsar ([`toggled()`](Self::toggled), +/// [`next_for()`](Self::next_for)). +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +pub enum SortDir { + /// Orden ascendente. Es la dirección por defecto. + #[default] + Asc, + /// Orden descendente. + Desc, +} + +impl SortDir { + /// Interpreta el valor del parámetro de ordenación procedente de una *query string* + /// (`"asc"`/`"desc"`). + /// + /// Cualquier valor distinto de `"desc"` (incluido `None`, una cadena vacía o un valor no + /// reconocido) se interpreta como [`Asc`](Self::Asc). + /// + /// ```rust + /// use pagetop::html::SortDir; + /// + /// assert_eq!(SortDir::from_query(Some("desc")), SortDir::Desc); + /// assert_eq!(SortDir::from_query(Some("asc")), SortDir::Asc); + /// assert_eq!(SortDir::from_query(Some("")), SortDir::Asc); + /// assert_eq!(SortDir::from_query(None), SortDir::Asc); + /// ``` + #[inline] + pub fn from_query(value: Option<&str>) -> Self { + match value { + Some("desc") => SortDir::Desc, + _ => SortDir::Asc, + } + } + + /// Devuelve el valor de esta dirección tal como se representa en una *query string* + /// (`"asc"`/`"desc"`). + /// + /// ```rust + /// use pagetop::html::SortDir; + /// + /// assert_eq!(SortDir::Asc.as_str(), "asc"); + /// assert_eq!(SortDir::Desc.as_str(), "desc"); + /// ``` + #[inline] + pub const fn as_str(self) -> &'static str { + match self { + SortDir::Asc => "asc", + SortDir::Desc => "desc", + } + } + + /// Devuelve la dirección contraria a esta. + /// + /// ```rust + /// use pagetop::html::SortDir; + /// + /// assert_eq!(SortDir::Asc.toggled(), SortDir::Desc); + /// assert_eq!(SortDir::Desc.toggled(), SortDir::Asc); + /// ``` + #[inline] + pub const fn toggled(self) -> Self { + match self { + SortDir::Asc => SortDir::Desc, + SortDir::Desc => SortDir::Asc, + } + } + + /// Calcula la dirección que debe llevar un enlace de ordenación si se vuelve a pulsar, dado su + /// estado actual (`current`) que puede ser `Some(dir)` si el listado ya está ordenado por este + /// enlace, o `None` si el orden vigente lo determina otro campo. + /// + /// Si el enlace ya ordena, [alterna la dirección](Self::toggled); si no, siempre empieza en + /// [`Asc`](Self::Asc), sea cual sea la dirección vigente del listado en conjunto. + /// + /// ```rust + /// use pagetop::html::SortDir; + /// + /// // Otro campo determina el orden vigente: el próximo clic aquí empieza en ascendente. + /// assert_eq!(SortDir::next_for(None), SortDir::Asc); + /// + /// // Este mismo campo ya ordena en ascendente: el próximo clic debería invertirlo. + /// assert_eq!(SortDir::next_for(Some(SortDir::Asc)), SortDir::Desc); + /// assert_eq!(SortDir::next_for(Some(SortDir::Desc)), SortDir::Asc); + /// ``` + #[inline] + pub const fn next_for(current: Option) -> SortDir { + match current { + Some(dir) => dir.toggled(), + None => SortDir::Asc, + } + } +} + +/// Permite pasar un [`SortDir`] allí donde se espere `impl Into`, por ejemplo, en +/// [`RoutePath::with_param()`](crate::html::RoutePath::with_param) o su equivalente +/// `alter_param()`, sin tener que escribir `as_str().to_owned()` a mano. +/// +/// ```rust +/// use pagetop::html::SortDir; +/// +/// assert_eq!(String::from(SortDir::Asc), "asc"); +/// assert_eq!(String::from(SortDir::Desc), "desc"); +/// ``` +impl From for String { + fn from(dir: SortDir) -> Self { + dir.as_str().to_owned() + } +}