Compare commits
2 commits
74dbcde5c5
...
ea0dc021ce
| Author | SHA1 | Date | |
|---|---|---|---|
| ea0dc021ce | |||
| 9db1d4bab0 |
4 changed files with 236 additions and 24 deletions
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
|
use crate::hx;
|
||||||
|
|
||||||
// **< HtmxResponse >*******************************************************************************
|
// **< HtmxResponse >*******************************************************************************
|
||||||
|
|
||||||
/// Generador de respuestas HTML parciales con cabeceras HTMX.
|
/// Generador de respuestas HTML parciales con cabeceras HTMX.
|
||||||
|
|
@ -18,7 +20,7 @@ use pagetop::prelude::*;
|
||||||
/// use pagetop::prelude::*;
|
/// use pagetop::prelude::*;
|
||||||
/// use pagetop_htmx::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" } };
|
/// let new_item = html! { li #item-42 { "New item" } };
|
||||||
///
|
///
|
||||||
/// HtmxResponse::new(new_item)
|
/// HtmxResponse::new(new_item)
|
||||||
|
|
@ -48,6 +50,21 @@ use pagetop::prelude::*;
|
||||||
/// - [`HtmxResponse::new(markup)`](Self::new), con el fragmento HTML.
|
/// - [`HtmxResponse::new(markup)`](Self::new), con el fragmento HTML.
|
||||||
/// - [`HtmxResponse::empty()`](Self::empty), sin cuerpo, sólo cabeceras.
|
/// - [`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
|
/// # Cabeceras disponibles
|
||||||
///
|
///
|
||||||
/// Los nombres de cabecera como constantes están en [`crate::hx::response`].
|
/// Los nombres de cabecera como constantes están en [`crate::hx::response`].
|
||||||
|
|
@ -86,6 +103,36 @@ impl HtmxResponse {
|
||||||
Self::new(html! {})
|
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 >*******************************************************************
|
// **< HtmxResponse BUILDER >*******************************************************************
|
||||||
|
|
||||||
/// Hace que HTMX realice una navegación AJAX a la URL indicada sin recargar la página.
|
/// 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<RoutePath>) -> Self {
|
pub fn location(self, url: impl Into<RoutePath>) -> 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
|
/// Hace que HTMX realice una navegación AJAX personalizada, con un objeto JSON de configuración
|
||||||
/// en lugar de una URL simple.
|
/// en lugar de una URL simple.
|
||||||
///
|
///
|
||||||
/// Acepta un objeto JSON con las claves `path`, `target`, `swap`, `select` y `values` (ver la
|
/// Acepta un objeto JSON con claves como `path`, `target`, `swap`, `select` o `values`, entre
|
||||||
/// [documentación de HTMX](https://htmx.org/reference/#response_headers) para el detalle de
|
/// otras (ver la [documentación de HTMX](https://htmx.org/reference/#response_headers) para el
|
||||||
/// cada una). Al no ser una URL, no admite `Context::route()`: si `path` necesita el parámetro
|
/// listado completo y el detalle de cada una). Al no ser una URL, no admite `Context::route()`:
|
||||||
/// `lang`, hay que componerlo a mano antes de construir el JSON. Para una navegación simple sin
|
/// si `path` necesita el parámetro `lang`, hay que componerlo a mano antes de construir el
|
||||||
/// estas opciones, usa [`location()`](Self::location).
|
/// 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
|
/// 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
|
/// 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;
|
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.
|
/// Empuja la URL indicada al historial del navegador.
|
||||||
|
|
@ -178,7 +225,7 @@ impl HtmxResponse {
|
||||||
/// # }
|
/// # }
|
||||||
/// ```
|
/// ```
|
||||||
pub fn push_url(self, url: impl Into<RoutePath>) -> Self {
|
pub fn push_url(self, url: impl Into<RoutePath>) -> 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.
|
/// 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
|
/// [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal para
|
||||||
/// que la URL preserve el parámetro `lang` cuando corresponda.
|
/// que la URL preserve el parámetro `lang` cuando corresponda.
|
||||||
pub fn replace_url(self, url: impl Into<RoutePath>) -> Self {
|
pub fn replace_url(self, url: impl Into<RoutePath>) -> 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.
|
/// Provoca una redirección completa del navegador a la URL indicada.
|
||||||
|
|
@ -207,14 +254,14 @@ impl HtmxResponse {
|
||||||
/// # }
|
/// # }
|
||||||
/// ```
|
/// ```
|
||||||
pub fn redirect(self, url: impl Into<RoutePath>) -> Self {
|
pub fn redirect(self, url: impl Into<RoutePath>) -> 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.
|
/// Provoca una recarga completa de la página actual.
|
||||||
///
|
///
|
||||||
/// Equivale a `window.location.reload()` en JavaScript.
|
/// Equivale a `window.location.reload()` en JavaScript.
|
||||||
pub fn refresh(self) -> Self {
|
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.
|
/// 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,
|
/// Útil cuando el servidor necesita actualizar un elemento distinto al que realizó la petición,
|
||||||
/// sin modificar el HTML del cliente.
|
/// sin modificar el HTML del cliente.
|
||||||
pub fn retarget(self, selector: impl Into<String>) -> Self {
|
pub fn retarget(self, selector: impl Into<String>) -> 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.
|
/// 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`,
|
/// Acepta los mismos valores que el atributo `hx-swap`, incluidos modificadores (`swap:200ms`,
|
||||||
/// `scroll:top`, ...). Los valores tipados están en [`crate::hx::swap`].
|
/// `scroll:top`, ...). Los valores tipados están en [`crate::hx::swap`].
|
||||||
pub fn reswap(self, strategy: impl Into<String>) -> Self {
|
pub fn reswap(self, strategy: impl Into<String>) -> 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
|
/// Anula el `hx-select` del elemento y selecciona el fragmento CSS indicado de la respuesta
|
||||||
/// para insertarlo en el objetivo.
|
/// para insertarlo en el objetivo.
|
||||||
pub fn reselect(self, selector: impl Into<String>) -> Self {
|
pub fn reselect(self, selector: impl Into<String>) -> 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.
|
/// Dispara uno o varios eventos JavaScript en el cliente al completar la respuesta.
|
||||||
|
|
@ -275,7 +322,7 @@ impl HtmxResponse {
|
||||||
/// HtmxResponse::empty().trigger(json);
|
/// HtmxResponse::empty().trigger(json);
|
||||||
/// ```
|
/// ```
|
||||||
pub fn trigger(self, event: impl Into<String>) -> Self {
|
pub fn trigger(self, event: impl Into<String>) -> 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
|
/// 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).
|
/// Acepta los mismos formatos que [`trigger()`](Self::trigger).
|
||||||
pub fn trigger_after_settle(self, event: impl Into<String>) -> Self {
|
pub fn trigger_after_settle(self, event: impl Into<String>) -> 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
|
/// 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).
|
/// Acepta los mismos formatos que [`trigger()`](Self::trigger).
|
||||||
pub fn trigger_after_swap(self, event: impl Into<String>) -> Self {
|
pub fn trigger_after_swap(self, event: impl Into<String>) -> 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<String>) -> Self {
|
fn set_header(mut self, name: &[u8], value: impl Into<String>) -> Self {
|
||||||
let value = value.into();
|
let value = value.into();
|
||||||
if let (Ok(n), Ok(v)) = (
|
if let (Ok(n), Ok(v)) = (
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ fn header<'a>(response: &'a web::Response, name: &str) -> Option<&'a str> {
|
||||||
response.headers().get(name)?.to_str().ok()
|
response.headers().get(name)?.to_str().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
// **< HtmxResponse::new() / empty() >**************************************************************
|
// **< HtmxResponse::new() / empty() / oob() >******************************************************
|
||||||
|
|
||||||
#[pagetop::test]
|
#[pagetop::test]
|
||||||
async fn new_renders_the_given_markup_with_an_html_content_type() {
|
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, "");
|
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#"<li id="item-42">New item</li>"#,
|
||||||
|
r#"<span id="item-count" hx-swap-oob="true">1</span>"#,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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!(
|
||||||
|
"<p>Main</p>",
|
||||||
|
r#"<span id="a" hx-swap-oob="true">A</span>"#,
|
||||||
|
r#"<span id="b" hx-swap-oob="true">B</span>"#,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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#"<span id="item-count" hx-swap-oob="true">0</span>"#);
|
||||||
|
}
|
||||||
|
|
||||||
// **< location() / location_json() >***************************************************************
|
// **< location() / location_json() >***************************************************************
|
||||||
|
|
||||||
#[pagetop::test]
|
#[pagetop::test]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Definición de entidades y acceso a la base de datos.
|
//! 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
|
//! Agrupa los *traits*, macros y tipos del sistema de entidades de SeaORM, junto con las funciones
|
||||||
//! [`dbconn`], [`execute`], [`fetch_all`] y [`fetch_one`], en una sola importación:
|
//! [`dbconn`], [`execute`], [`fetch_all`], [`fetch_one`] y [`paginate`], en una sola importación:
|
||||||
//!
|
//!
|
||||||
//! ```rust,no_run
|
//! ```rust,no_run
|
||||||
//! use pagetop_seaorm::db::*;
|
//! use pagetop_seaorm::db::*;
|
||||||
|
|
@ -30,7 +30,8 @@
|
||||||
//! - **Macros de derivación**: [`DeriveEntityModel`], [`DeriveColumn`], [`DerivePrimaryKey`],
|
//! - **Macros de derivación**: [`DeriveEntityModel`], [`DeriveColumn`], [`DerivePrimaryKey`],
|
||||||
//! [`DeriveRelation`], [`EnumIter`].
|
//! [`DeriveRelation`], [`EnumIter`].
|
||||||
//! - **Errores**: [`DbErr`].
|
//! - **Errores**: [`DbErr`].
|
||||||
//! - **Resultados**: [`QueryResult`] (filas sin tipar), [`ExecResult`] (INSERT/UPDATE/DELETE).
|
//! - **Resultados**: [`QueryResult`] (filas sin tipar), [`ExecResult`] (INSERT/UPDATE/DELETE),
|
||||||
|
//! [`Paginated`] (página de resultados).
|
||||||
//!
|
//!
|
||||||
//! # Definir una entidad
|
//! # Definir una entidad
|
||||||
//!
|
//!
|
||||||
|
|
@ -106,6 +107,17 @@
|
||||||
//!
|
//!
|
||||||
//! Para migraciones y definición de esquemas usa [`migration`](crate::migration).
|
//! 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
|
//! # Acceso completo a SeaORM
|
||||||
//!
|
//!
|
||||||
//! Este módulo re-exporta el crate `sea_orm` íntegro. Úsalo cuando necesites un tipo o función que
|
//! Este módulo re-exporta el crate `sea_orm` íntegro. Úsalo cuando necesites un tipo o función que
|
||||||
|
|
@ -316,3 +328,110 @@ pub async fn fetch_one<Q: query::QueryStatementWriter>(
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **< Paginated / paginate >***********************************************************************
|
||||||
|
|
||||||
|
/// Página de resultados de una consulta paginada.
|
||||||
|
pub struct Paginated<T> {
|
||||||
|
/// Elementos de esta página.
|
||||||
|
pub items: Vec<T>,
|
||||||
|
/// 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<T> Default for Paginated<T> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Paginated {
|
||||||
|
items: Vec::new(),
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
per_page: 1,
|
||||||
|
total_pages: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Paginated<T> {
|
||||||
|
/// 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<T>`) y devuelve, de forma
|
||||||
|
/// asíncrona, el resultado de la transformación (`Result<Vec<U>, E>`).
|
||||||
|
pub async fn map_items<U, E, Fut>(
|
||||||
|
self,
|
||||||
|
f: impl FnOnce(Vec<T>) -> Fut,
|
||||||
|
) -> Result<Paginated<U>, E>
|
||||||
|
where
|
||||||
|
Fut: std::future::Future<Output = Result<Vec<U>, 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<E>(
|
||||||
|
select: Select<E>,
|
||||||
|
page: u64,
|
||||||
|
per_page: u64,
|
||||||
|
) -> Result<Paginated<E::Model>, 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -174,13 +174,13 @@ async fn example() -> Result<(), DbErr> {
|
||||||
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
|
include_locales!(LOCALES_SEAORM);
|
||||||
|
|
||||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
|
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
include_locales!(LOCALES_SEAORM);
|
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue