diff --git a/extensions/pagetop-htmx/src/response.rs b/extensions/pagetop-htmx/src/response.rs index cf512633..0712ae20 100644 --- a/extensions/pagetop-htmx/src/response.rs +++ b/extensions/pagetop-htmx/src/response.rs @@ -2,8 +2,6 @@ use pagetop::prelude::*; -use crate::hx; - // **< HtmxResponse >******************************************************************************* /// Generador de respuestas HTML parciales con cabeceras HTMX. @@ -20,7 +18,7 @@ use crate::hx; /// use pagetop::prelude::*; /// use pagetop_htmx::prelude::*; /// -/// async fn add_item() -> impl IntoResponse { +/// async fn add_item(request: HttpRequest) -> impl IntoResponse { /// let new_item = html! { li #item-42 { "New item" } }; /// /// HtmxResponse::new(new_item) @@ -50,21 +48,6 @@ use crate::hx; /// - [`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`]. @@ -103,36 +86,6 @@ 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. @@ -153,17 +106,17 @@ impl HtmxResponse { /// # } /// ``` pub fn location(self, url: impl Into) -> Self { - self.set_header(hx::response::LOCATION.as_bytes(), url.into().to_string()) + 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 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). + /// 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 @@ -205,7 +158,7 @@ impl HtmxResponse { ); return self; } - self.set_header(hx::response::LOCATION.as_bytes(), json) + self.set_header(b"hx-location", json) } /// Empuja la URL indicada al historial del navegador. @@ -225,7 +178,7 @@ impl HtmxResponse { /// # } /// ``` pub fn push_url(self, url: impl Into) -> Self { - self.set_header(hx::response::PUSH_URL.as_bytes(), url.into().to_string()) + self.set_header(b"hx-push-url", url.into().to_string()) } /// Reemplaza la URL actual en el historial sin añadir una nueva entrada. @@ -234,7 +187,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(hx::response::REPLACE_URL.as_bytes(), url.into().to_string()) + self.set_header(b"hx-replace-url", url.into().to_string()) } /// Provoca una redirección completa del navegador a la URL indicada. @@ -254,14 +207,14 @@ impl HtmxResponse { /// # } /// ``` pub fn redirect(self, url: impl Into) -> Self { - self.set_header(hx::response::REDIRECT.as_bytes(), url.into().to_string()) + self.set_header(b"hx-redirect", 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(hx::response::REFRESH.as_bytes(), "true") + self.set_header(b"hx-refresh", "true") } /// Anula el `hx-target` del elemento y redirige la respuesta al selector CSS indicado. @@ -269,7 +222,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(hx::response::RETARGET.as_bytes(), selector) + self.set_header(b"hx-retarget", selector) } /// Anula el `hx-swap` del elemento e impone la estrategia de sustitución indicada. @@ -277,13 +230,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(hx::response::RESWAP.as_bytes(), strategy) + self.set_header(b"hx-reswap", 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(hx::response::RESELECT.as_bytes(), selector) + self.set_header(b"hx-reselect", selector) } /// Dispara uno o varios eventos JavaScript en el cliente al completar la respuesta. @@ -322,7 +275,7 @@ impl HtmxResponse { /// HtmxResponse::empty().trigger(json); /// ``` pub fn trigger(self, event: impl Into) -> Self { - self.set_header(hx::response::TRIGGER.as_bytes(), event) + self.set_header(b"hx-trigger", event) } /// Dispara eventos JavaScript después de que HTMX haya aplicado la respuesta al DOM y haya @@ -330,7 +283,7 @@ impl HtmxResponse { /// /// Acepta los mismos formatos que [`trigger()`](Self::trigger). pub fn trigger_after_settle(self, event: impl Into) -> Self { - self.set_header(hx::response::TRIGGER_AFTER_SETTLE.as_bytes(), event) + self.set_header(b"hx-trigger-after-settle", event) } /// Dispara eventos JavaScript después de que HTMX haya aplicado la respuesta al DOM, pero antes @@ -338,12 +291,10 @@ impl HtmxResponse { /// /// Acepta los mismos formatos que [`trigger()`](Self::trigger). pub fn trigger_after_swap(self, event: impl Into) -> Self { - self.set_header(hx::response::TRIGGER_AFTER_SWAP.as_bytes(), event) + self.set_header(b"hx-trigger-after-swap", event) } - // 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`). + // Inserta o reemplaza una cabecera. Los nombres deben ser bytes ASCII en minúsculas. 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 278f52c1..5a75d70e 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() / oob() >****************************************************** +// **< HtmxResponse::new() / empty() >************************************************************** #[pagetop::test] async fn new_renders_the_given_markup_with_an_html_content_type() { @@ -39,50 +39,6 @@ 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] diff --git a/extensions/pagetop-seaorm/src/db.rs b/extensions/pagetop-seaorm/src/db.rs index 0741fd33..7e3474cb 100644 --- a/extensions/pagetop-seaorm/src/db.rs +++ b/extensions/pagetop-seaorm/src/db.rs @@ -1,7 +1,7 @@ //! Definición de entidades y acceso a la base de datos. //! //! Agrupa los *traits*, macros y tipos del sistema de entidades de SeaORM, junto con las funciones -//! [`dbconn`], [`execute`], [`fetch_all`], [`fetch_one`] y [`paginate`], en una sola importación: +//! [`dbconn`], [`execute`], [`fetch_all`] y [`fetch_one`], en una sola importación: //! //! ```rust,no_run //! use pagetop_seaorm::db::*; @@ -30,8 +30,7 @@ //! - **Macros de derivación**: [`DeriveEntityModel`], [`DeriveColumn`], [`DerivePrimaryKey`], //! [`DeriveRelation`], [`EnumIter`]. //! - **Errores**: [`DbErr`]. -//! - **Resultados**: [`QueryResult`] (filas sin tipar), [`ExecResult`] (INSERT/UPDATE/DELETE), -//! [`Paginated`] (página de resultados). +//! - **Resultados**: [`QueryResult`] (filas sin tipar), [`ExecResult`] (INSERT/UPDATE/DELETE). //! //! # Definir una entidad //! @@ -107,17 +106,6 @@ //! //! Para migraciones y definición de esquemas usa [`migration`](crate::migration). //! -//! # Paginación -//! -//! [`paginate`] ejecuta una consulta paginada sobre una entidad y devuelve un [`Paginated`] con los -//! elementos de la página junto con su metadata (`total`, `page`, `per_page`, `total_pages`). Es el -//! camino habitual para listados administrables (usuarios, roles...). -//! -//! Cuando cada elemento necesita enriquecerse con datos de otra tabla que no vienen incluidos en -//! la propia consulta paginada (una colección asociada, un conteo relacionado...), -//! [`Paginated::map_items`] aplica esa transformación de forma asíncrona y falible sin perder la -//! metadata de paginación ya calculada. -//! //! # Acceso completo a SeaORM //! //! Este módulo re-exporta el crate `sea_orm` íntegro. Úsalo cuando necesites un tipo o función que @@ -328,110 +316,3 @@ pub async fn fetch_one( )) .await } - -// **< Paginated / paginate >*********************************************************************** - -/// Página de resultados de una consulta paginada. -pub struct Paginated { - /// Elementos de esta página. - pub items: Vec, - /// Número total de registros que cumplen la consulta, sin paginar. - pub total: u64, - /// Página actual, empezando en `1`. - pub page: u64, - /// Número de elementos por página. - pub per_page: u64, - /// Número total de páginas. - pub total_pages: u64, -} - -// Implementación manual en lugar de `#[derive(Default)]`, que exigiría un `T: Default` innecesario, -// ya que una página vacía no requiere que el tipo de elemento lo sea. -impl Default for Paginated { - fn default() -> Self { - Paginated { - items: Vec::new(), - total: 0, - page: 1, - per_page: 1, - total_pages: 1, - } - } -} - -impl Paginated { - /// Transforma los elementos de la página con una función asíncrona y falible, conservando el - /// resto de la metadata de paginación (`total`, `page`, `per_page`, `total_pages`). - /// - /// * `f` - función que recibe los elementos actuales (`Vec`) y devuelve, de forma - /// asíncrona, el resultado de la transformación (`Result, E>`). - pub async fn map_items( - self, - f: impl FnOnce(Vec) -> Fut, - ) -> Result, E> - where - Fut: std::future::Future, E>>, - { - let items = f(self.items).await?; - Ok(Paginated { - items, - total: self.total, - page: self.page, - per_page: self.per_page, - total_pages: self.total_pages, - }) - } -} - -/// Ejecuta una consulta paginada con el sistema de entidades y devuelve la página solicitada. -/// -/// Añade la metadata de paginación (`total`, `total_pages`); `page` y `per_page` se ajustan a un -/// mínimo de `1`, ya que no existe la página `0` ni un tamaño de página vacío. -/// -/// ```rust,no_run -/// use pagetop_seaorm::db::*; -/// -/// #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] -/// #[sea_orm(table_name = "users")] -/// pub struct Model { -/// #[sea_orm(primary_key)] -/// pub id: i32, -/// pub email: String, -/// } -/// -/// #[derive(Clone, Copy, Debug, EnumIter, DeriveRelation)] -/// pub enum Relation {} -/// -/// impl ActiveModelBehavior for ActiveModel {} -/// -/// async fn example() -> Result<(), DbErr> { -/// let page = paginate(Entity::find(), 1, 20).await?; -/// println!("{} usuarios en {} páginas", page.total, page.total_pages); -/// Ok(()) -/// } -/// ``` -pub async fn paginate( - select: Select, - page: u64, - per_page: u64, -) -> Result, DbErr> -where - E: EntityTrait, - E::Model: sea_orm::FromQueryResult + Send + Sync, -{ - let per_page = per_page.max(1); - let page = page.max(1); - let paginator = select.paginate(dbconn(), per_page); - let sea_orm::ItemsAndPagesNumber { - number_of_items: total, - number_of_pages: total_pages, - } = paginator.num_items_and_pages().await?; - let items = paginator.fetch_page(page.saturating_sub(1)).await?; - Ok(Paginated { - items, - total, - page, - per_page, - total_pages, - }) -} diff --git a/extensions/pagetop-seaorm/src/lib.rs b/extensions/pagetop-seaorm/src/lib.rs index bb23c366..eff88193 100644 --- a/extensions/pagetop-seaorm/src/lib.rs +++ b/extensions/pagetop-seaorm/src/lib.rs @@ -174,13 +174,13 @@ async fn example() -> Result<(), DbErr> { use pagetop::prelude::*; -include_locales!(LOCALES_SEAORM); - use sea_orm::{ConnectOptions, Database, DatabaseConnection}; use url::Url; use std::sync::OnceLock; +include_locales!(LOCALES_SEAORM); + pub mod config; pub mod db;