✨ (auth): Añade idioma y tema preferidos al usuario
- CurrentUser pasa de enum a struct con `id` opcional (anónimo = None) y preferencias de idioma, zona horaria y tema validadas en `with_*()`. - `RequestLocale` tiene en cuenta el idioma preferido del usuario, y Context usa su tema si lo tiene (`default_theme()` en otro caso). - Nuevos componentes `form::SelectLanguage`, `form::SelectTheme` y `form::SelectTimezone`, con `Timezone::supported_by_region()` para listar y validar las zonas IANA ofrecidas.
This commit is contained in:
parent
35f2b66161
commit
70744a51a5
25 changed files with 1162 additions and 190 deletions
178
src/auth.rs
178
src/auth.rs
|
|
@ -17,11 +17,14 @@
|
|||
//! [`Context`]: crate::core::component::Context
|
||||
|
||||
use crate::core::action::{ActionDispatcher, try_dispatch_actions};
|
||||
use crate::core::theme::{ThemeRef, theme_by_short_name};
|
||||
use crate::datetime::{Timezone, Tz};
|
||||
use crate::locale::Lc;
|
||||
use crate::locale::{LanguageIdentifier, Lc, Locale};
|
||||
use crate::response::ErrorPage;
|
||||
use crate::web::HttpRequest;
|
||||
use crate::{CowStr, Weight};
|
||||
use crate::{AutoDefault, CowStr, Getters, Weight, builder_impl};
|
||||
|
||||
use std::ops::ControlFlow;
|
||||
|
||||
// **< CurrentUser >********************************************************************************
|
||||
|
||||
|
|
@ -29,76 +32,133 @@ use crate::{CowStr, Weight};
|
|||
///
|
||||
/// Se almacena automáticamente en el [`Context`] a partir de la petición HTTP. La identidad se
|
||||
/// extrae de las extensiones de la petición, que una extensión de autenticación inyecta mediante su
|
||||
/// middleware.
|
||||
/// middleware. Sin extensión de autenticación, o si ésta no inyecta ninguna identidad, el usuario
|
||||
/// es anónimo ([`CurrentUser::anonymous()`], que también es el valor por defecto).
|
||||
///
|
||||
/// Se accede usando [`Contextual::current_user()`].
|
||||
///
|
||||
/// Los usuarios pueden tener idioma, zona horaria y tema preferidos. Se asignan con su valor en
|
||||
/// bruto y se validan al asignarlos: un idioma no soportado, una zona horaria desconocida o un tema
|
||||
/// no habilitado en la aplicación se descartan y el dato queda sin valor, como si el usuario no
|
||||
/// tuviera ninguno. Así, las preferencias de un `CurrentUser` son siempre válidas.
|
||||
///
|
||||
/// Los datos extendidos del usuario autenticado (roles, permisos, cuenta completa, ...) son
|
||||
/// responsabilidad de la extensión de autenticación y se obtienen a través de
|
||||
/// [`HttpRequest::extension`].
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let user = CurrentUser::authenticated(42, "Alice")
|
||||
/// .with_language("es-ES")
|
||||
/// .with_timezone("Europe/Madrid");
|
||||
/// ```
|
||||
///
|
||||
/// [`Context`]: crate::core::component::Context
|
||||
/// [`Contextual::current_user()`]: crate::core::component::Contextual::current_user
|
||||
/// [`HttpRequest::extension`]: crate::web::HttpRequest::extension
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CurrentUser {
|
||||
/// Usuario no autenticado.
|
||||
Anonymous,
|
||||
/// Usuario autenticado con su identificador, nombre visible y zona horaria propia.
|
||||
Authenticated {
|
||||
/// Identificador único del usuario en el sistema.
|
||||
id: i32,
|
||||
/// Nombre visible del usuario.
|
||||
display_name: String,
|
||||
/// Zona horaria del usuario, si tiene una configurada y es válida. En otro caso valdrá
|
||||
/// `None` y [`timezone()`](Self::timezone) devolverá la zona horaria predeterminada de la
|
||||
/// aplicación.
|
||||
timezone: Option<Tz>,
|
||||
},
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct CurrentUser {
|
||||
/// Devuelve el identificador del usuario, o `None` si es anónimo.
|
||||
#[getters(copy)]
|
||||
id: Option<i32>,
|
||||
// Siempre `Some` en un usuario autenticado y `None` en uno anónimo, igual que `id`.
|
||||
#[getters(skip)]
|
||||
display_name: Option<String>,
|
||||
/// Devuelve el idioma preferido del usuario, o `None` si no tiene ninguno.
|
||||
///
|
||||
/// Lo tiene en cuenta [`RequestLocale`](crate::locale::RequestLocale) al decidir el idioma de
|
||||
/// la petición.
|
||||
#[getters(copy)]
|
||||
language: Option<&'static LanguageIdentifier>,
|
||||
// Ver `timezone()`, que devuelve la zona horaria efectiva.
|
||||
#[getters(skip)]
|
||||
timezone: Option<Tz>,
|
||||
/// Devuelve el tema preferido del usuario, o `None` si no tiene ninguno.
|
||||
///
|
||||
/// Lo tiene en cuenta el [`Context`](crate::core::component::Context) de la petición al elegir
|
||||
/// el tema con el que se renderiza.
|
||||
#[getters(copy)]
|
||||
theme: Option<ThemeRef>,
|
||||
}
|
||||
|
||||
#[builder_impl]
|
||||
impl CurrentUser {
|
||||
/// Crea un usuario anónimo, sin idioma, zona horaria ni tema preferidos.
|
||||
pub fn anonymous() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Crea un usuario autenticado, sin idioma, zona horaria ni tema preferidos.
|
||||
pub fn authenticated(id: i32, display_name: impl Into<String>) -> Self {
|
||||
CurrentUser {
|
||||
id: Some(id),
|
||||
display_name: Some(display_name.into()),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
// **< CurrentUser BUILDER >********************************************************************
|
||||
|
||||
/// Asigna el idioma preferido a partir de su identificador (p. ej. `"es-ES"` o `"es"`).
|
||||
///
|
||||
/// Se resuelve con [`Locale::resolve()`](crate::locale::Locale::resolve); si el idioma no está
|
||||
/// soportado por la aplicación, o es `None`, el usuario queda sin idioma preferido.
|
||||
pub fn with_language<'a>(mut self, language: impl Into<Option<&'a str>>) -> Self {
|
||||
self.language = language
|
||||
.into()
|
||||
.and_then(|language| Locale::resolve(language).as_option());
|
||||
self
|
||||
}
|
||||
|
||||
/// Asigna la zona horaria a partir de su nombre IANA (p. ej. `"Europe/Madrid"`).
|
||||
///
|
||||
/// Si el nombre no corresponde a ninguna zona horaria conocida, o es `None`, el usuario queda
|
||||
/// sin zona horaria propia.
|
||||
pub fn with_timezone<'a>(mut self, timezone: impl Into<Option<&'a str>>) -> Self {
|
||||
self.timezone = timezone.into().and_then(|timezone| timezone.parse().ok());
|
||||
self
|
||||
}
|
||||
|
||||
/// Asigna el tema preferido a partir de su nombre corto (p. ej. `"basic"`).
|
||||
///
|
||||
/// Se busca con [`theme_by_short_name()`](crate::core::theme::theme_by_short_name); si el tema
|
||||
/// no está habilitado en la aplicación, o es `None`, el usuario queda sin tema preferido.
|
||||
pub fn with_theme<'a>(mut self, theme: impl Into<Option<&'a str>>) -> Self {
|
||||
self.theme = theme.into().and_then(theme_by_short_name);
|
||||
self
|
||||
}
|
||||
|
||||
// **< CurrentUser GETTERS >********************************************************************
|
||||
|
||||
/// Devuelve `true` si el usuario no está autenticado.
|
||||
pub fn is_anonymous(&self) -> bool {
|
||||
matches!(self, CurrentUser::Anonymous)
|
||||
self.id.is_none()
|
||||
}
|
||||
|
||||
/// Devuelve `true` si el usuario está autenticado.
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
matches!(self, CurrentUser::Authenticated { .. })
|
||||
}
|
||||
|
||||
/// Devuelve el identificador del usuario, o `None` si es anónimo.
|
||||
pub fn id(&self) -> Option<i32> {
|
||||
match self {
|
||||
CurrentUser::Anonymous => None,
|
||||
CurrentUser::Authenticated { id, .. } => Some(*id),
|
||||
}
|
||||
self.id.is_some()
|
||||
}
|
||||
|
||||
/// Devuelve el nombre visible del usuario, o `None` si es anónimo.
|
||||
pub fn display_name(&self) -> Option<&str> {
|
||||
match self {
|
||||
CurrentUser::Anonymous => None,
|
||||
CurrentUser::Authenticated { display_name, .. } => Some(display_name),
|
||||
}
|
||||
self.display_name.as_deref()
|
||||
}
|
||||
|
||||
/// Devuelve la zona horaria efectiva del usuario.
|
||||
///
|
||||
/// Un usuario autenticado devuelve la suya si tiene una configurada y es válida; en cualquier
|
||||
/// otro caso (incluido el usuario anónimo), devuelve [`Timezone::default_tz()`].
|
||||
/// Devuelve la suya si tiene una; en otro caso, devuelve [`Timezone::default_tz()`].
|
||||
///
|
||||
/// Normalmente se resuelve una sola vez, al construir el `Context` de la petición. A partir de
|
||||
/// ese momento el renderizado del documento no vuelve a llamarlo porque usa el valor ya
|
||||
/// resuelto vía [`Contextual::timezone()`](crate::core::component::Contextual::timezone).
|
||||
/// Normalmente se resuelve una sola vez, al construir el [`Context`] de la petición. A partir
|
||||
/// de ese momento el renderizado del documento no vuelve a llamarlo porque usa el valor ya
|
||||
/// resuelto vía [`Contextual::timezone()`].
|
||||
///
|
||||
/// [`Context`]: crate::core::component::Context
|
||||
/// [`Contextual::timezone()`]: crate::core::component::Contextual::timezone
|
||||
pub fn timezone(&self) -> Tz {
|
||||
match self {
|
||||
CurrentUser::Anonymous => Timezone::default_tz(),
|
||||
CurrentUser::Authenticated { timezone, .. } => {
|
||||
timezone.unwrap_or_else(Timezone::default_tz)
|
||||
}
|
||||
}
|
||||
self.timezone.unwrap_or_else(Timezone::default_tz)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -113,8 +173,7 @@ impl CurrentUser {
|
|||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::auth::Permission;
|
||||
/// # use pagetop::CowStr;
|
||||
/// # use pagetop::prelude::*;
|
||||
/// #[derive(Clone, Copy, Debug)]
|
||||
/// pub enum MyPermission {
|
||||
/// EditPosts,
|
||||
|
|
@ -222,21 +281,6 @@ impl CheckPermission {
|
|||
self.weight = value;
|
||||
self
|
||||
}
|
||||
|
||||
// Despacha las acciones registradas con salida anticipada en cuanto una concede el permiso.
|
||||
#[inline]
|
||||
pub(crate) fn check(request: &HttpRequest, perm: PermissionRef) -> bool {
|
||||
let mut granted = false;
|
||||
try_dispatch_actions(|action: &Self| {
|
||||
(action.f)(request, perm, &mut granted);
|
||||
if granted {
|
||||
std::ops::ControlFlow::Break(())
|
||||
} else {
|
||||
std::ops::ControlFlow::Continue(())
|
||||
}
|
||||
});
|
||||
granted
|
||||
}
|
||||
}
|
||||
|
||||
// **< has_permission >*****************************************************************************
|
||||
|
|
@ -267,7 +311,17 @@ impl CheckPermission {
|
|||
/// }
|
||||
/// ```
|
||||
pub fn has_permission(request: &HttpRequest, perm: PermissionRef) -> bool {
|
||||
CheckPermission::check(request, perm)
|
||||
// Despacha las acciones registradas con salida anticipada en cuanto una concede el permiso.
|
||||
let mut granted = false;
|
||||
try_dispatch_actions(|action: &CheckPermission| {
|
||||
(action.f)(request, perm, &mut granted);
|
||||
if granted {
|
||||
ControlFlow::Break(())
|
||||
} else {
|
||||
ControlFlow::Continue(())
|
||||
}
|
||||
});
|
||||
granted
|
||||
}
|
||||
|
||||
// **< require_permission >*************************************************************************
|
||||
|
|
@ -303,8 +357,6 @@ pub fn has_permission(request: &HttpRequest, perm: PermissionRef) -> bool {
|
|||
/// .await
|
||||
/// }
|
||||
/// ```
|
||||
// `ErrorPage` incluye `Option<HttpRequest>` en cada variante y es el tipo de error ya establecido
|
||||
// para toda la respuesta HTTP; boxearlo aquí sólo para esta función no compensa.
|
||||
pub fn require_permission(request: &HttpRequest, perm: PermissionRef) -> Result<(), ErrorPage> {
|
||||
if has_permission(request, perm) {
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -18,6 +18,15 @@ pub mod radio;
|
|||
|
||||
pub mod select;
|
||||
|
||||
mod select_language;
|
||||
pub use select_language::SelectLanguage;
|
||||
|
||||
mod select_theme;
|
||||
pub use select_theme::SelectTheme;
|
||||
|
||||
mod select_timezone;
|
||||
pub use select_timezone::SelectTimezone;
|
||||
|
||||
pub mod input;
|
||||
|
||||
mod number;
|
||||
|
|
|
|||
130
src/base/component/form/select_language.rs
Normal file
130
src/base/component/form/select_language.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
use crate::prelude::*;
|
||||
|
||||
/// Componente para **elegir un idioma** de la lista de idiomas soportados por PageTop.
|
||||
///
|
||||
/// Ofrece un elemento por cada idioma de [`Locale::supported_languages()`], con su identificador
|
||||
/// como valor (p. ej. `"es-ES"`) y su nombre traducido como etiqueta, ordenados por ese nombre en
|
||||
/// el idioma de la página. Se renderiza como cualquier [`form::select::Field`].
|
||||
///
|
||||
/// La primera opción, con valor vacío, depende de si el campo es obligatorio:
|
||||
///
|
||||
/// - Si no lo es (por defecto), siempre se incluye y propone usar el idioma del sitio. Quedarse sin
|
||||
/// idioma propio es válido y significa usar el predeterminado de la aplicación. Se selecciona
|
||||
/// cuando el valor elegido no corresponde a ningún idioma de la lista.
|
||||
/// - Si lo es ([`with_required(true)`](Self::with_required)), sólo se incluye cuando el valor
|
||||
/// seleccionado no corresponde a ningún idioma de la lista, y pide elegir uno; así el navegador
|
||||
/// no deja enviar el formulario sin elegir un idioma. El valor recibido debe validarse
|
||||
/// igualmente en el servidor.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let language = form::SelectLanguage::new()
|
||||
/// .with_name("language")
|
||||
/// .with_label(Lc::n("Language"))
|
||||
/// .with_selected("es-ES");
|
||||
/// ```
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct SelectLanguage {
|
||||
/// Devuelve la lista de selección interna con la configuración común (nombre, etiqueta, ayuda,
|
||||
/// propiedades...), todavía sin opciones; éstas se añaden al renderizar.
|
||||
field: form::select::Field,
|
||||
/// Devuelve el identificador del idioma seleccionado.
|
||||
selected: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for SelectLanguage {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn id(&self) -> Option<String> {
|
||||
self.field().id()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let mut field = self.field().clone();
|
||||
let mut languages = Locale::supported_languages();
|
||||
languages.sort_by_cached_key(|(_, name)| name.collation_key(&*cx));
|
||||
|
||||
let selected = Locale::resolve(self.selected()).as_option();
|
||||
let known = selected.is_some();
|
||||
if !field.required() {
|
||||
let default_langid = Locale::default_langid();
|
||||
let default_name = languages
|
||||
.iter()
|
||||
.find(|(langid, _)| *langid == default_langid)
|
||||
.and_then(|(_, name)| name.lookup(cx))
|
||||
.unwrap_or_else(|| default_langid.to_string());
|
||||
let label = Lc::l("select_language_site_default").with_arg("language", default_name);
|
||||
field.alter_item(form::select::Item::new("", label).with_selected(!known));
|
||||
} else if !known {
|
||||
let label = Lc::l("select_language_placeholder");
|
||||
field.alter_item(form::select::Item::new("", label).with_selected(true));
|
||||
}
|
||||
|
||||
for (langid, name) in languages {
|
||||
let item = form::select::Item::new(langid.to_string(), name);
|
||||
field.alter_item(item.with_selected(selected == Some(langid)));
|
||||
}
|
||||
Ok(field.render(cx).await)
|
||||
}
|
||||
}
|
||||
|
||||
#[builder_impl]
|
||||
impl SelectLanguage {
|
||||
// **< SelectLanguage BUILDER >*****************************************************************
|
||||
|
||||
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
|
||||
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
|
||||
self.field.alter_id(id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
|
||||
pub fn with_prop(mut self, op: impl Into<PropsOp>) -> Self {
|
||||
self.field.alter_prop(op);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el nombre del campo.
|
||||
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
|
||||
self.field.alter_name(name);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece la etiqueta del campo.
|
||||
pub fn with_label(mut self, label: Lc) -> Self {
|
||||
self.field.alter_label(label);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el texto de ayuda del campo.
|
||||
pub fn with_help_text(mut self, help_text: Lc) -> Self {
|
||||
self.field.alter_help_text(help_text);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece si el campo es obligatorio, lo que cambia su primera opción (ver
|
||||
/// [`SelectLanguage`]).
|
||||
pub fn with_required(mut self, required: bool) -> Self {
|
||||
self.field.alter_required(required);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece si el campo está deshabilitado.
|
||||
pub fn with_disabled(mut self, disabled: bool) -> Self {
|
||||
self.field.alter_disabled(disabled);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el identificador del idioma seleccionado (p. ej. `"es-ES"`). Se resuelve con
|
||||
/// [`Locale::resolve()`], así que también acepta alias o variantes (`"es"`, `"es-es"`...) del
|
||||
/// mismo idioma.
|
||||
pub fn with_selected(mut self, selected: impl Into<String>) -> Self {
|
||||
self.selected = selected.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
140
src/base/component/form/select_theme.rs
Normal file
140
src/base/component/form/select_theme.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
use crate::prelude::*;
|
||||
|
||||
/// Componente para **elegir un tema** de los temas habilitados en la aplicación.
|
||||
///
|
||||
/// Ofrece un elemento por cada tema de [`enabled_themes()`], con su nombre corto como valor (p. ej.
|
||||
/// `"Bootsier"`) y su nombre traducido como etiqueta, ordenados por ese nombre en el idioma de la
|
||||
/// página. Se renderiza como cualquier [`form::select::Field`].
|
||||
///
|
||||
/// Si sólo hay un tema habilitado, la lista se muestra deshabilitada con ese tema seleccionado (un
|
||||
/// campo deshabilitado no se envía con el formulario). Si hay varios, la primera opción, con valor
|
||||
/// vacío, depende de si el campo es obligatorio:
|
||||
///
|
||||
/// - Si no lo es (por defecto), siempre se incluye y propone usar el tema del sitio. Quedarse sin
|
||||
/// tema propio es válido y significa usar el predeterminado de la aplicación. Se selecciona
|
||||
/// cuando el valor elegido no corresponde a ningún tema de la lista (sin distinguir mayúsculas y
|
||||
/// minúsculas).
|
||||
/// - Si lo es ([`with_required(true)`](Self::with_required)), sólo se incluye cuando el valor
|
||||
/// seleccionado no corresponde a ningún tema de la lista, y pide elegir uno; así el navegador no
|
||||
/// deja enviar el formulario sin elegir un tema. El valor recibido debe validarse igualmente en
|
||||
/// el servidor.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let theme = form::SelectTheme::new()
|
||||
/// .with_name("theme")
|
||||
/// .with_label(Lc::n("Theme"))
|
||||
/// .with_selected("Basic");
|
||||
/// ```
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct SelectTheme {
|
||||
/// Devuelve la lista de selección interna con la configuración común (nombre, etiqueta, ayuda,
|
||||
/// propiedades...), todavía sin opciones; éstas se añaden al renderizar.
|
||||
field: form::select::Field,
|
||||
/// Devuelve el nombre corto del tema seleccionado.
|
||||
selected: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for SelectTheme {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn id(&self) -> Option<String> {
|
||||
self.field().id()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let mut field = self.field().clone();
|
||||
let mut themes = enabled_themes();
|
||||
|
||||
if let [theme] = themes[..] {
|
||||
field.alter_disabled(true);
|
||||
field.alter_item(
|
||||
form::select::Item::new(theme.short_name(), theme.name()).with_selected(true),
|
||||
);
|
||||
return Ok(field.render(cx).await);
|
||||
}
|
||||
|
||||
themes.sort_by_cached_key(|theme| theme.name().collation_key(&*cx));
|
||||
let is_selected =
|
||||
|theme: ThemeRef| theme.short_name().eq_ignore_ascii_case(self.selected());
|
||||
|
||||
let known = themes.iter().any(|theme| is_selected(*theme));
|
||||
if !field.required() {
|
||||
let default_theme = default_theme();
|
||||
let default_name = default_theme
|
||||
.name()
|
||||
.lookup(cx)
|
||||
.unwrap_or_else(|| default_theme.short_name().to_owned());
|
||||
let label = Lc::l("select_theme_site_default").with_arg("theme", default_name);
|
||||
field.alter_item(form::select::Item::new("", label).with_selected(!known));
|
||||
} else if !known {
|
||||
let label = Lc::l("select_theme_placeholder");
|
||||
field.alter_item(form::select::Item::new("", label).with_selected(true));
|
||||
}
|
||||
|
||||
for theme in themes {
|
||||
let item = form::select::Item::new(theme.short_name(), theme.name());
|
||||
field.alter_item(item.with_selected(is_selected(theme)));
|
||||
}
|
||||
Ok(field.render(cx).await)
|
||||
}
|
||||
}
|
||||
|
||||
#[builder_impl]
|
||||
impl SelectTheme {
|
||||
// **< SelectTheme BUILDER >********************************************************************
|
||||
|
||||
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
|
||||
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
|
||||
self.field.alter_id(id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
|
||||
pub fn with_prop(mut self, op: impl Into<PropsOp>) -> Self {
|
||||
self.field.alter_prop(op);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el nombre del campo.
|
||||
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
|
||||
self.field.alter_name(name);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece la etiqueta del campo.
|
||||
pub fn with_label(mut self, label: Lc) -> Self {
|
||||
self.field.alter_label(label);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el texto de ayuda del campo.
|
||||
pub fn with_help_text(mut self, help_text: Lc) -> Self {
|
||||
self.field.alter_help_text(help_text);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece si el campo es obligatorio, lo que cambia su primera opción (ver [`SelectTheme`]).
|
||||
pub fn with_required(mut self, required: bool) -> Self {
|
||||
self.field.alter_required(required);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece si el campo está deshabilitado. Con un solo tema habilitado lo está siempre.
|
||||
pub fn with_disabled(mut self, disabled: bool) -> Self {
|
||||
self.field.alter_disabled(disabled);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el nombre corto del tema seleccionado (p. ej. `"Bootsier"`). Vacío, o uno que no
|
||||
/// esté habilitado, selecciona la primera opción (ver [`SelectTheme`]).
|
||||
pub fn with_selected(mut self, selected: impl Into<String>) -> Self {
|
||||
self.selected = selected.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
142
src/base/component/form/select_timezone.rs
Normal file
142
src/base/component/form/select_timezone.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
use crate::prelude::*;
|
||||
|
||||
/// Componente para crear una **lista de selección de zonas horarias** IANA.
|
||||
///
|
||||
/// Ofrece las zonas horarias de [`Timezone::supported_by_region()`] agrupadas por región, con el
|
||||
/// nombre de la región traducido (*"Europa"*, *"América"*...) y el nombre IANA completo como valor
|
||||
/// y como etiqueta (p. ej. `"Europe/Madrid"`). No admite opciones libres. Se renderiza como
|
||||
/// cualquier [`form::select::Field`].
|
||||
///
|
||||
/// La primera opción, con valor vacío, depende de si el campo es obligatorio:
|
||||
///
|
||||
/// - Si no lo es (por defecto), siempre se incluye y propone usar la zona horaria del sitio.
|
||||
/// Quedarse sin zona propia es válido y significa usar la predeterminada de la aplicación. Se
|
||||
/// selecciona cuando el valor elegido no corresponde a ninguna zona de la lista.
|
||||
/// - Si lo es ([`with_required(true)`](Self::with_required)), sólo se incluye cuando el valor
|
||||
/// seleccionado no corresponde a ninguna zona de la lista, y pide elegir una; así el navegador no
|
||||
/// deja enviar el formulario sin elegir una zona horaria. El valor recibido debe validarse
|
||||
/// igualmente en el servidor.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let timezone = form::SelectTimezone::new()
|
||||
/// .with_name("timezone")
|
||||
/// .with_label(Lc::n("Time zone"))
|
||||
/// .with_selected("Europe/Madrid");
|
||||
/// ```
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct SelectTimezone {
|
||||
/// Devuelve la lista de selección interna con la configuración común (nombre, etiqueta, ayuda,
|
||||
/// propiedades...), todavía sin opciones; éstas se añaden al renderizar.
|
||||
field: form::select::Field,
|
||||
/// Devuelve el nombre IANA de la zona horaria seleccionada.
|
||||
selected: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for SelectTimezone {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn id(&self) -> Option<String> {
|
||||
self.field().id()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let mut field = self.field().clone();
|
||||
let regions = Timezone::supported_by_region();
|
||||
let known = regions
|
||||
.iter()
|
||||
.any(|(_, names)| names.contains(&self.selected()));
|
||||
if !field.required() {
|
||||
let label = Lc::l("select_timezone_site_default")
|
||||
.with_arg("timezone", Timezone::default_tz().name());
|
||||
field.alter_item(form::select::Item::new("", label).with_selected(!known));
|
||||
} else if !known {
|
||||
let label = Lc::l("select_timezone_placeholder");
|
||||
field.alter_item(form::select::Item::new("", label).with_selected(true));
|
||||
}
|
||||
for (region, names) in regions {
|
||||
let mut group = form::select::Group::new(match *region {
|
||||
"Africa" => Lc::l("timezone_region_africa"),
|
||||
"America" => Lc::l("timezone_region_america"),
|
||||
"Antarctica" => Lc::l("timezone_region_antarctica"),
|
||||
"Arctic" => Lc::l("timezone_region_arctic"),
|
||||
"Asia" => Lc::l("timezone_region_asia"),
|
||||
"Atlantic" => Lc::l("timezone_region_atlantic"),
|
||||
"Australia" => Lc::l("timezone_region_australia"),
|
||||
"Etc" => Lc::l("timezone_region_etc"),
|
||||
"Europe" => Lc::l("timezone_region_europe"),
|
||||
"Indian" => Lc::l("timezone_region_indian"),
|
||||
"Pacific" => Lc::l("timezone_region_pacific"),
|
||||
_ => Lc::n(*region),
|
||||
});
|
||||
for name in names {
|
||||
let selected = *name == self.selected();
|
||||
group.alter_item(
|
||||
form::select::Item::new(*name, Lc::n(*name)).with_selected(selected),
|
||||
);
|
||||
}
|
||||
field.alter_group(group);
|
||||
}
|
||||
Ok(field.render(cx).await)
|
||||
}
|
||||
}
|
||||
|
||||
#[builder_impl]
|
||||
impl SelectTimezone {
|
||||
// **< SelectTimezone BUILDER >*****************************************************************
|
||||
|
||||
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
|
||||
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
|
||||
self.field.alter_id(id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
|
||||
pub fn with_prop(mut self, op: impl Into<PropsOp>) -> Self {
|
||||
self.field.alter_prop(op);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el nombre del campo.
|
||||
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
|
||||
self.field.alter_name(name);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece la etiqueta del campo.
|
||||
pub fn with_label(mut self, label: Lc) -> Self {
|
||||
self.field.alter_label(label);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el texto de ayuda del campo.
|
||||
pub fn with_help_text(mut self, help_text: Lc) -> Self {
|
||||
self.field.alter_help_text(help_text);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece si el campo es obligatorio, lo que cambia su primera opción (ver
|
||||
/// [`SelectTimezone`]).
|
||||
pub fn with_required(mut self, required: bool) -> Self {
|
||||
self.field.alter_required(required);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece si el campo está deshabilitado.
|
||||
pub fn with_disabled(mut self, disabled: bool) -> Self {
|
||||
self.field.alter_disabled(disabled);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el nombre IANA de la zona horaria seleccionada (p. ej. `"Europe/Madrid"`). Vacía,
|
||||
/// o una que no esté en la lista, selecciona la primera opción (ver [`SelectTimezone`]).
|
||||
pub fn with_selected(mut self, selected: impl Into<String>) -> Self {
|
||||
self.selected = selected.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
use crate::auth::CurrentUser;
|
||||
use crate::core::TypeInfo;
|
||||
use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage};
|
||||
use crate::core::theme::all::DEFAULT_THEME;
|
||||
use crate::core::theme::{ChildrenInRegions, CoreRegions, CoreTemplates};
|
||||
use crate::core::theme::{ChildrenInRegions, CoreRegions, CoreTemplates, default_theme};
|
||||
use crate::core::theme::{RegionRef, TemplateRef, ThemeRef};
|
||||
use crate::datetime::Tz;
|
||||
use crate::html::{Assets, Favicon, JavaScript, Preload, ResponsiveStyles, StyleSheet};
|
||||
|
|
@ -32,7 +31,7 @@ pub use contextual::Contextual;
|
|||
/// [`Page::new()`](crate::response::Page::new) o [`Page::admin()`](crate::response::Page::admin)),
|
||||
/// y es la única vía por la que un componente, una acción o el tema activo conocen: la petición
|
||||
/// HTTP de origen, el idioma negociado y la zona horaria efectiva, el usuario autenticado
|
||||
/// ([`current_user()`](Contextual::current_user)), la plantilla y el tema en uso, y los recursos
|
||||
/// ([`current_user()`](Contextual::current_user)), el tema y la plantilla en uso, y los recursos
|
||||
/// (favicon, hojas de estilo, scripts) acumulados hasta ese momento. Otros datos que los
|
||||
/// componentes necesiten durante el renderizado pueden ser parámetros dinámicos tipados con
|
||||
/// [`with_param()`](Contextual::with_param)/[`param()`](Contextual::param).
|
||||
|
|
@ -99,8 +98,8 @@ pub struct Context {
|
|||
locale : RequestLocale, // Idioma asociado a la petición.
|
||||
current_user: CurrentUser, // Identidad del usuario actual.
|
||||
timezone : Tz, // Zona horaria efectiva del documento.
|
||||
template : TemplateRef, // Plantilla usada para renderizar.
|
||||
theme : ThemeRef, // Referencia al tema usado para renderizar.
|
||||
template : TemplateRef, // Plantilla usada para renderizar.
|
||||
favicon : Option<Favicon>, // Favicon, si se ha definido.
|
||||
preloads : Assets<Preload>, // Recursos para precarga.
|
||||
stylesheets : Assets<StyleSheet>, // Hojas de estilo CSS.
|
||||
|
|
@ -129,13 +128,14 @@ impl Context {
|
|||
let locale = RequestLocale::from_request(request.as_ref());
|
||||
let current_user = Self::resolve_current_user(request.as_ref());
|
||||
let timezone = current_user.timezone();
|
||||
let theme = current_user.theme().unwrap_or_else(default_theme);
|
||||
Context {
|
||||
request,
|
||||
locale,
|
||||
current_user,
|
||||
timezone,
|
||||
theme,
|
||||
template,
|
||||
theme : *DEFAULT_THEME,
|
||||
favicon : None,
|
||||
preloads : Assets::<Preload>::new(),
|
||||
stylesheets: Assets::<StyleSheet>::new(),
|
||||
|
|
@ -169,12 +169,12 @@ impl Context {
|
|||
}
|
||||
|
||||
// Extrae el `CurrentUser` inyectado por middleware en las extensiones de la petición, o
|
||||
// `CurrentUser::Anonymous` si no hay petición o ninguna extensión de autenticación está activa.
|
||||
// un usuario anónimo si no hay petición o ninguna extensión de autenticación está activa.
|
||||
fn resolve_current_user(request: Option<&HttpRequest>) -> CurrentUser {
|
||||
request
|
||||
.and_then(|r| r.extension::<CurrentUser>())
|
||||
.cloned()
|
||||
.unwrap_or(CurrentUser::Anonymous)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// **< Context RENDER >*************************************************************************
|
||||
|
|
@ -320,8 +320,9 @@ impl Context {
|
|||
|
||||
/// Permite a [`Context`] actuar como proveedor de idioma.
|
||||
///
|
||||
/// Internamente delega en [`RequestLocale`], que tiene en cuenta la petición HTTP, la configuración
|
||||
/// global de idioma de la aplicación, la cabecera `Accept-Language` y/o el idioma de respaldo.
|
||||
/// Internamente delega en [`RequestLocale`], que tiene en cuenta la petición HTTP (parámetro
|
||||
/// `?lang` e idioma preferido del usuario), la configuración global de idioma de la aplicación, la
|
||||
/// cabecera `Accept-Language` y/o el idioma de respaldo.
|
||||
///
|
||||
/// Todo ello según la negociación indicada en [`global::SETTINGS.app.lang_negotiation`]. Esto
|
||||
/// permite que el [`Context`] se use como fuente de idioma coherente en [`Lc::lookup()`] o
|
||||
|
|
@ -344,11 +345,12 @@ impl Contextual for Context {
|
|||
|
||||
fn with_request(mut self, request: Option<HttpRequest>) -> Self {
|
||||
self.request = request;
|
||||
// Recalcula el *locale*, el usuario actual y la zona horaria según la nueva petición y la
|
||||
// política de negociación configurada.
|
||||
// Recalcula el *locale*, el usuario actual, la zona horaria y el tema según la nueva
|
||||
// petición y la política de negociación configurada.
|
||||
self.locale = RequestLocale::from_request(self.request.as_ref());
|
||||
self.current_user = Self::resolve_current_user(self.request.as_ref());
|
||||
self.timezone = self.current_user.timezone();
|
||||
self.theme = self.current_user.theme().unwrap_or_else(default_theme);
|
||||
self
|
||||
}
|
||||
|
||||
|
|
@ -362,13 +364,13 @@ impl Contextual for Context {
|
|||
self
|
||||
}
|
||||
|
||||
fn with_template(mut self, template: TemplateRef) -> Self {
|
||||
self.template = template;
|
||||
fn with_theme(mut self, theme: ThemeRef) -> Self {
|
||||
self.theme = theme;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_theme(mut self, theme: ThemeRef) -> Self {
|
||||
self.theme = theme;
|
||||
fn with_template(mut self, template: TemplateRef) -> Self {
|
||||
self.template = template;
|
||||
self
|
||||
}
|
||||
|
||||
|
|
@ -452,14 +454,14 @@ impl Contextual for Context {
|
|||
self.timezone
|
||||
}
|
||||
|
||||
fn template(&self) -> TemplateRef {
|
||||
self.template
|
||||
}
|
||||
|
||||
fn theme(&self) -> ThemeRef {
|
||||
self.theme
|
||||
}
|
||||
|
||||
fn template(&self) -> TemplateRef {
|
||||
self.template
|
||||
}
|
||||
|
||||
fn param<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> {
|
||||
let (any, type_name) = self.params.get(key).ok_or(ContextError::ParamNotFound)?;
|
||||
any.downcast_ref::<T>()
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const ISO_DATETIME: &str = "%Y-%m-%dT%H:%M:%S%:z";
|
|||
/// - Almacenar la **petición HTTP** de origen.
|
||||
/// - Conocer la **identidad del usuario actual** ([`current_user()`](Self::current_user)) y la
|
||||
/// **zona horaria efectiva** del documento ([`timezone()`](Self::timezone)).
|
||||
/// - Seleccionar la **plantilla** y el **tema** de renderizado.
|
||||
/// - Seleccionar el **tema** y la **plantilla** de renderizado.
|
||||
/// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo
|
||||
/// [`StyleSheet`] o los scripts [`JavaScript`], directamente o mediante una operación
|
||||
/// [`AssetsOp`].
|
||||
|
|
@ -40,8 +40,8 @@ const ISO_DATETIME: &str = "%Y-%m-%dT%H:%M:%S%:z";
|
|||
/// # use pagetop_aliner::Aliner;
|
||||
/// fn prepare_context<C: Contextual>(cx: C) -> C {
|
||||
/// cx.with_langid(&Locale::resolve("es-ES"))
|
||||
/// .with_template(&CoreTemplates::Standard)
|
||||
/// .with_theme(&Aliner)
|
||||
/// .with_template(&CoreTemplates::Standard)
|
||||
/// .with_assets(Favicon::new().with_icon("/favicon.ico"))
|
||||
/// .with_assets(StyleSheet::from("/css/app.css"))
|
||||
/// .with_assets(JavaScript::defer("/js/app.js"))
|
||||
|
|
@ -67,22 +67,24 @@ pub trait Contextual: LangId {
|
|||
///
|
||||
/// Al asociar la petición, recalcula el idioma ([`RequestLocale::from_request()`]), establece
|
||||
/// el usuario actual ([`current_user()`]) y, a partir de éste, asigna la zona horaria efectiva
|
||||
/// ([`timezone()`]), descartando en el proceso cualquier idioma o zona horaria anteriores.
|
||||
/// ([`timezone()`]) y el tema ([`theme()`]), descartando en el proceso cualquier idioma, zona
|
||||
/// horaria o tema anteriores.
|
||||
///
|
||||
/// Si sabes que vas a forzar el idioma o la zona horaria, llama a `with_request()` primero en
|
||||
/// la cadena de construcción, nunca después.
|
||||
/// Si sabes que vas a forzar el idioma, la zona horaria o el tema, llama a `with_request()`
|
||||
/// primero en la cadena de construcción, nunca después.
|
||||
///
|
||||
/// [`RequestLocale::from_request()`]: crate::locale::RequestLocale::from_request
|
||||
/// [`current_user()`]: Self::current_user
|
||||
/// [`timezone()`]: Self::timezone
|
||||
/// [`theme()`]: Self::theme
|
||||
fn with_request(self, request: Option<HttpRequest>) -> Self;
|
||||
|
||||
/// Especifica la plantilla para renderizar el documento.
|
||||
fn with_template(self, template: TemplateRef) -> Self;
|
||||
|
||||
/// Especifica el tema para renderizar el documento.
|
||||
fn with_theme(self, theme: ThemeRef) -> Self;
|
||||
|
||||
/// Especifica la plantilla para renderizar el documento.
|
||||
fn with_template(self, template: TemplateRef) -> Self;
|
||||
|
||||
/// Añade o modifica un parámetro dinámico del contexto.
|
||||
///
|
||||
/// El valor se almacena junto con el nombre de su tipo, lo que permite generar mensajes de
|
||||
|
|
@ -125,7 +127,7 @@ pub trait Contextual: LangId {
|
|||
///
|
||||
/// Si ninguna extensión de autenticación ha inyectado un
|
||||
/// [`CurrentUser`](crate::auth::CurrentUser) en las extensiones de la petición HTTP, devuelve
|
||||
/// `&CurrentUser::Anonymous`.
|
||||
/// un usuario anónimo ([`CurrentUser::anonymous()`](crate::auth::CurrentUser::anonymous)).
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
|
|
@ -154,12 +156,12 @@ pub trait Contextual: LangId {
|
|||
/// [`CurrentUser::timezone()`]: crate::auth::CurrentUser::timezone
|
||||
fn timezone(&self) -> Tz;
|
||||
|
||||
/// Devuelve la plantilla configurada para renderizar el documento.
|
||||
fn template(&self) -> TemplateRef;
|
||||
|
||||
/// Devuelve el tema que se usará para renderizar el documento.
|
||||
fn theme(&self) -> ThemeRef;
|
||||
|
||||
/// Devuelve la plantilla configurada para renderizar el documento.
|
||||
fn template(&self) -> TemplateRef;
|
||||
|
||||
/// Recupera una *referencia tipada* al parámetro solicitado.
|
||||
///
|
||||
/// Devuelve:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::core::action::publish_actions;
|
||||
use crate::core::extension::ExtensionRef;
|
||||
use crate::core::theme::ThemeRef;
|
||||
use crate::core::theme::all::THEMES;
|
||||
use crate::core::theme::all::register_theme;
|
||||
use crate::web::Router;
|
||||
use crate::{global, serve_static_files, trace, web};
|
||||
|
||||
|
|
@ -47,14 +47,7 @@ fn add_to_enabled(list: &mut Vec<ExtensionRef>, extension: ExtensionRef) {
|
|||
// Comprueba si la extensión tiene un tema asociado que deba registrarse.
|
||||
if let Some(theme) = extension.theme() {
|
||||
check_theme_parent_chain(theme);
|
||||
|
||||
let mut registered_themes = THEMES.write();
|
||||
// Asegura que el tema no esté ya registrado para evitar duplicados.
|
||||
if !registered_themes
|
||||
.iter()
|
||||
.any(|t| t.type_id() == theme.type_id())
|
||||
{
|
||||
registered_themes.push(theme);
|
||||
if register_theme(theme) {
|
||||
trace::debug!("Enabling \"{}\" theme", theme.short_name());
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -170,3 +170,4 @@ pub(crate) use regions::ChildrenInRegions;
|
|||
pub use regions::InRegion;
|
||||
|
||||
pub(crate) mod all;
|
||||
pub use all::{default_theme, enabled_themes, theme_by_short_name};
|
||||
|
|
|
|||
|
|
@ -7,27 +7,50 @@ use std::sync::LazyLock;
|
|||
|
||||
// **< TEMAS >**************************************************************************************
|
||||
|
||||
pub static THEMES: LazyLock<RwLock<Vec<ThemeRef>>> = LazyLock::new(|| RwLock::new(Vec::new()));
|
||||
static THEMES: LazyLock<RwLock<Vec<ThemeRef>>> = LazyLock::new(|| RwLock::new(Vec::new()));
|
||||
|
||||
// Registra el tema si no lo estaba ya, para evitar duplicados. Devuelve `true` si lo ha añadido.
|
||||
pub(crate) fn register_theme(theme: ThemeRef) -> bool {
|
||||
let mut themes = THEMES.write();
|
||||
if themes.iter().any(|t| t.type_id() == theme.type_id()) {
|
||||
return false;
|
||||
}
|
||||
themes.push(theme);
|
||||
true
|
||||
}
|
||||
|
||||
/// Devuelve los temas habilitados en la aplicación, en el orden en que se registraron.
|
||||
pub fn enabled_themes() -> Vec<ThemeRef> {
|
||||
THEMES.read().clone()
|
||||
}
|
||||
|
||||
/// Devuelve el tema identificado por su [`short_name()`](crate::core::AnyInfo::short_name), si está
|
||||
/// habilitado, sin distinguir mayúsculas y minúsculas.
|
||||
pub fn theme_by_short_name(short_name: &str) -> Option<ThemeRef> {
|
||||
THEMES
|
||||
.read()
|
||||
.iter()
|
||||
.find(|t| t.short_name().eq_ignore_ascii_case(short_name))
|
||||
.copied()
|
||||
}
|
||||
|
||||
// **< TEMA PREDETERMINADO >************************************************************************
|
||||
|
||||
pub static DEFAULT_THEME: LazyLock<ThemeRef> =
|
||||
static DEFAULT_THEME: LazyLock<ThemeRef> =
|
||||
LazyLock::new(|| match theme_by_short_name(&global::SETTINGS.app.theme) {
|
||||
Some(theme) => theme,
|
||||
None => &crate::base::theme::Basic,
|
||||
});
|
||||
|
||||
// **< TEMA POR NOMBRE >****************************************************************************
|
||||
|
||||
/// Devuelve el tema identificado por su [`short_name()`](AnyInfo::short_name).
|
||||
pub fn theme_by_short_name(short_name: &'static str) -> Option<ThemeRef> {
|
||||
let short_name = short_name.to_lowercase();
|
||||
match THEMES
|
||||
.read()
|
||||
.iter()
|
||||
.find(|t| t.short_name().to_lowercase() == short_name)
|
||||
{
|
||||
Some(theme) => Some(*theme),
|
||||
_ => None,
|
||||
}
|
||||
/// Devuelve el tema predeterminado de la aplicación: el configurado en `app.theme` si está
|
||||
/// habilitado o, en otro caso, [`Basic`](crate::base::theme::Basic).
|
||||
///
|
||||
/// Es el tema del sitio, no necesariamente el que se usa en una petición concreta: para renderizar,
|
||||
/// el tema efectivo es el de [`Contextual::theme()`], que tiene en cuenta el tema preferido del
|
||||
/// usuario ([`CurrentUser::theme()`]).
|
||||
///
|
||||
/// [`Contextual::theme()`]: crate::core::component::Contextual::theme
|
||||
/// [`CurrentUser::theme()`]: crate::auth::CurrentUser::theme
|
||||
pub fn default_theme() -> ThemeRef {
|
||||
*DEFAULT_THEME
|
||||
}
|
||||
|
|
|
|||
|
|
@ -431,6 +431,12 @@ pub trait Theme: Extension + Send + Sync {
|
|||
/// Referencia estática a un tema.
|
||||
pub type ThemeRef = &'static dyn Theme;
|
||||
|
||||
impl std::fmt::Debug for dyn Theme {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.short_name())
|
||||
}
|
||||
}
|
||||
|
||||
// **< setup_component! >***************************************************************************
|
||||
|
||||
/// Modifica un componente dentro de [`Theme::setup_component()`].
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
use crate::{global, trace, util};
|
||||
|
||||
use super::Tz;
|
||||
use super::{TZ_VARIANTS, Tz};
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
// Identificador de zona horaria configurado para la aplicación, si es válido.
|
||||
|
|
@ -17,6 +18,36 @@ static CONFIG_TZ: LazyLock<Option<Tz>> = LazyLock::new(|| {
|
|||
// Zona horaria de respaldo, garantizada incluso sin configuración válida.
|
||||
const FALLBACK_TZ: Tz = Tz::UTC;
|
||||
|
||||
// Regiones de la base IANA que sólo contienen alias heredados (fichero `backward`), todos con una
|
||||
// zona canónica equivalente en otra región (p. ej. `US/Eastern` es `America/New_York`).
|
||||
const LEGACY_REGIONS: [&str; 5] = ["Brazil", "Canada", "Chile", "Mexico", "US"];
|
||||
|
||||
// Zonas horarias IANA agrupadas por región (lo anterior a la primera `/`), ordenadas por región y
|
||||
// nombre. Se descartan los nombres sin región (alias heredados como `GB`, `Japan` o `EST5EDT`) y
|
||||
// las regiones de `LEGACY_REGIONS`. De `Etc` sólo se conserva `Etc/UTC`, cuyo grupo va al final:
|
||||
// el resto son zonas de desfase fijo (`Etc/GMT+1`...) que no representan ningún lugar. Siguen
|
||||
// apareciendo los alias heredados que viven dentro de una región normal (p. ej. `Asia/Calcutta`
|
||||
// junto a `Asia/Kolkata`): `chrono-tz` no distingue zonas canónicas de enlaces y filtrarlos
|
||||
// exigiría mantener a mano una lista de casi 180 nombres.
|
||||
static TZ_BY_REGION: LazyLock<Vec<(&'static str, Vec<&'static str>)>> = LazyLock::new(|| {
|
||||
let mut regions: BTreeMap<&'static str, Vec<&'static str>> = BTreeMap::new();
|
||||
for tz in TZ_VARIANTS.iter() {
|
||||
let name = tz.name();
|
||||
let Some((region, _)) = name.split_once('/') else {
|
||||
continue;
|
||||
};
|
||||
if LEGACY_REGIONS.contains(®ion) || (region == "Etc" && name != "Etc/UTC") {
|
||||
continue;
|
||||
}
|
||||
regions.entry(region).or_default().push(name);
|
||||
}
|
||||
for names in regions.values_mut() {
|
||||
names.sort_unstable();
|
||||
}
|
||||
let etc = regions.remove_entry("Etc");
|
||||
regions.into_iter().chain(etc).collect()
|
||||
});
|
||||
|
||||
/// Zona horaria configurada para la aplicación.
|
||||
///
|
||||
/// Resuelve [`global::SETTINGS.app.timezone`](crate::global::App::timezone) contra la base IANA de
|
||||
|
|
@ -69,4 +100,31 @@ impl Timezone {
|
|||
pub fn default_tz() -> Tz {
|
||||
Self::try_tz().unwrap_or(FALLBACK_TZ)
|
||||
}
|
||||
|
||||
/// Devuelve las zonas horarias IANA que se ofrecen para elegir, agrupadas por región.
|
||||
///
|
||||
/// Cada grupo es la región (lo anterior a la primera `/`, p. ej. `"Europe"`) con los nombres
|
||||
/// completos de sus zonas (p. ej. `"Europe/Madrid"`), ordenados por región y nombre; el grupo
|
||||
/// `"Etc"`, sólo con `"Etc/UTC"`, va al final. Se excluyen los nombres sin región (`"UTC"`,
|
||||
/// `"Japan"`...), las regiones formadas sólo por alias heredados (`"US"`, `"Canada"`...) y las
|
||||
/// zonas de desfase fijo (`"Etc/GMT+1"`...). Es la lista que ofrece
|
||||
/// [`form::SelectTimezone`](crate::base::component::form::SelectTimezone), útil también para
|
||||
/// validar el valor recibido.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let is_supported = |name: &str| {
|
||||
/// Timezone::supported_by_region()
|
||||
/// .iter()
|
||||
/// .any(|(_, names)| names.contains(&name))
|
||||
/// };
|
||||
///
|
||||
/// assert!(is_supported("Europe/Madrid"));
|
||||
/// assert!(!is_supported("US/Eastern"));
|
||||
/// ```
|
||||
pub fn supported_by_region() -> &'static [(&'static str, Vec<&'static str>)] {
|
||||
&TZ_BY_REGION
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,21 +11,25 @@ use serde::{Deserialize, Deserializer};
|
|||
#[derive(AutoDefault, Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum LangNegotiation {
|
||||
/// Usa todas las fuentes disponibles para determinar el idioma, en este orden: comprueba el
|
||||
/// parámetro `?lang` de la URL; si no está presente o no es válido, usa la cabecera HTTP
|
||||
/// `Accept-Language`; si tampoco está disponible o no es válido, usa el idioma configurado en
|
||||
/// [`global::SETTINGS.app.language`](crate::global::App::language) o, en su defecto, el idioma
|
||||
/// de respaldo. Es el comportamiento por defecto.
|
||||
/// parámetro `?lang` de la URL; si no está presente o no es válido, usa el idioma preferido del
|
||||
/// usuario ([`CurrentUser::language()`]); si no tiene ninguno, usa el idioma configurado en
|
||||
/// [`global::SETTINGS.app.language`]; si tampoco está disponible o no es válido, usa la
|
||||
/// cabecera HTTP `Accept-Language` o, en su defecto, el idioma de respaldo. Es el
|
||||
/// comportamiento por defecto.
|
||||
///
|
||||
/// [`CurrentUser::language()`]: crate::auth::CurrentUser::language
|
||||
/// [`global::SETTINGS.app.language`]: crate::global::App::language
|
||||
#[default]
|
||||
Full,
|
||||
|
||||
/// Igual que `LangNegotiation::Full`, pero sin tener en cuenta el parámetro `?lang` de la URL.
|
||||
/// El idioma depende únicamente de la cabecera `Accept-Language` del navegador y, en última
|
||||
/// instancia, de la configuración o idioma de respaldo.
|
||||
/// El idioma depende, en este orden, del idioma preferido del usuario, de la configuración, de
|
||||
/// la cabecera `Accept-Language` del navegador y, en última instancia, del idioma de respaldo.
|
||||
NoQuery,
|
||||
|
||||
/// Usa sólo la configuración o, en su defecto, el idioma de respaldo; ignora la cabecera
|
||||
/// `Accept-Language` y el parámetro de la URL. Este modo proporciona un comportamiento estable
|
||||
/// con idioma fijo.
|
||||
/// Usa sólo la configuración o, en su defecto, el idioma de respaldo; ignora el idioma del
|
||||
/// usuario, la cabecera `Accept-Language` y el parámetro de la URL. Este modo proporciona un
|
||||
/// comportamiento estable con idioma fijo.
|
||||
ConfigOnly,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::{global, trace, util};
|
||||
|
||||
use super::languages::LANGUAGES;
|
||||
use super::{LanguageIdentifier, langid};
|
||||
use super::languages::{LANGUAGES, SUPPORTED};
|
||||
use super::{LanguageIdentifier, Lc, langid};
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
|
|
@ -139,6 +139,33 @@ impl Locale {
|
|||
}
|
||||
}
|
||||
|
||||
/// Devuelve los idiomas soportados por PageTop con su nombre traducible, ordenados por
|
||||
/// identificador.
|
||||
///
|
||||
/// Incluye una entrada por identificador canónico (p. ej. `"es-ES"`), sin los alias de idioma
|
||||
/// base (`"es"`) que [`Locale::resolve()`] también acepta. El nombre es, por tanto, el de la
|
||||
/// variante regional (p. ej. *"Español (España)"*, no *"Español"*). Útil para ofrecer un
|
||||
/// selector de idioma.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let codes: Vec<String> = Locale::supported_languages()
|
||||
/// .iter()
|
||||
/// .map(|(langid, _)| langid.to_string())
|
||||
/// .collect();
|
||||
///
|
||||
/// assert!(codes.contains(&"es-ES".to_string()));
|
||||
/// assert!(!codes.contains(&"es".to_string()));
|
||||
/// ```
|
||||
pub fn supported_languages() -> Vec<(&'static LanguageIdentifier, Lc)> {
|
||||
SUPPORTED
|
||||
.iter()
|
||||
.map(|(langid, key)| (*langid, Lc::l(*key)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// **< Locale HELPERS >*************************************************************************
|
||||
|
||||
/// Inicializa el idioma por defecto que utilizará la aplicación.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,27 @@ dropdown_default_title = Dropdown
|
|||
# Form components.
|
||||
field_required = This field is required
|
||||
|
||||
select_language_site_default = Use the site language: { $language }
|
||||
select_language_placeholder = Choose a language...
|
||||
|
||||
select_theme_site_default = Use the site theme: { $theme }
|
||||
select_theme_placeholder = Choose a theme...
|
||||
|
||||
select_timezone_site_default = Use the site time zone: { $timezone }
|
||||
select_timezone_placeholder = Choose a time zone...
|
||||
|
||||
timezone_region_africa = Africa
|
||||
timezone_region_america = America
|
||||
timezone_region_antarctica = Antarctica
|
||||
timezone_region_arctic = Arctic
|
||||
timezone_region_asia = Asia
|
||||
timezone_region_atlantic = Atlantic
|
||||
timezone_region_australia = Australia
|
||||
timezone_region_europe = Europe
|
||||
timezone_region_indian = Indian Ocean
|
||||
timezone_region_pacific = Pacific
|
||||
timezone_region_etc = Other
|
||||
|
||||
# Intro component.
|
||||
intro_default_title = Hello, world!
|
||||
intro_default_slogan = Discover⚡{ $app }
|
||||
|
|
|
|||
|
|
@ -11,6 +11,27 @@ dropdown_default_title = Menú desplegable
|
|||
# Form components.
|
||||
field_required = Este campo es obligatorio
|
||||
|
||||
select_language_site_default = Usar el idioma del sitio: { $language }
|
||||
select_language_placeholder = Elige un idioma...
|
||||
|
||||
select_theme_site_default = Usar el tema del sitio: { $theme }
|
||||
select_theme_placeholder = Elige un tema...
|
||||
|
||||
select_timezone_site_default = Usar la zona horaria del sitio: { $timezone }
|
||||
select_timezone_placeholder = Elige una zona horaria...
|
||||
|
||||
timezone_region_africa = África
|
||||
timezone_region_america = América
|
||||
timezone_region_antarctica = Antártida
|
||||
timezone_region_arctic = Ártico
|
||||
timezone_region_asia = Asia
|
||||
timezone_region_atlantic = Atlántico
|
||||
timezone_region_australia = Australia
|
||||
timezone_region_europe = Europa
|
||||
timezone_region_indian = Océano Índico
|
||||
timezone_region_pacific = Pacífico
|
||||
timezone_region_etc = Otras
|
||||
|
||||
# Intro component.
|
||||
intro_default_title = ¡Hola, mundo!
|
||||
intro_default_slogan = Descubre⚡{ $app }
|
||||
|
|
|
|||
|
|
@ -27,3 +27,38 @@ pub(super) static LANGUAGES: LazyLock<HashMap<&str, (LanguageIdentifier, &str)>>
|
|||
"es-es" => ( langid!("es-ES"), "spanish_spain" ),
|
||||
]
|
||||
});
|
||||
|
||||
// Idiomas soportados sin alias: una entrada por identificador canónico (la de `LANGUAGES` cuyo
|
||||
// código coincide con él, p. ej. "es-es" y no "es"), ordenadas por identificador, con la clave de
|
||||
// su nombre.
|
||||
pub(super) static SUPPORTED: LazyLock<Vec<(&'static LanguageIdentifier, &'static str)>> =
|
||||
LazyLock::new(|| {
|
||||
let mut supported: Vec<_> = LANGUAGES
|
||||
.iter()
|
||||
.filter(|(code, (langid, _))| langid.to_string().eq_ignore_ascii_case(code))
|
||||
.map(|(_, (langid, key))| (langid, *key))
|
||||
.collect();
|
||||
supported.sort_by_cached_key(|(langid, _)| langid.to_string());
|
||||
supported
|
||||
});
|
||||
|
||||
// Un idioma añadido a `LANGUAGES` sólo con su alias (p. ej. "ca" sin "ca-es") lo aceptaría
|
||||
// `Locale::resolve()`, pero quedaría fuera de `SUPPORTED` y, con él, del selector de idioma.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_language_has_its_canonical_entry() {
|
||||
for (code, (langid, _)) in LANGUAGES.iter() {
|
||||
assert_eq!(
|
||||
SUPPORTED
|
||||
.iter()
|
||||
.filter(|(supported, _)| *supported == langid)
|
||||
.count(),
|
||||
1,
|
||||
"language \"{code}\" has no canonical entry \"{langid}\" in LANGUAGES"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::auth::CurrentUser;
|
||||
use crate::global;
|
||||
use crate::util;
|
||||
use crate::web::HttpRequest;
|
||||
|
|
@ -16,8 +17,8 @@ use super::{LangId, LanguageIdentifier, Locale};
|
|||
///
|
||||
/// [`LangNegotiation`]: crate::global::LangNegotiation
|
||||
pub struct RequestLocale {
|
||||
// Idioma elegido por la aplicación para esta petición, combinando la configuración, la cabecera
|
||||
// `Accept-Language` y/o el idioma de respaldo.
|
||||
// Idioma elegido por la aplicación para esta petición, combinando el idioma del usuario, la
|
||||
// configuración, la cabecera `Accept-Language` y/o el idioma de respaldo.
|
||||
base: &'static LanguageIdentifier,
|
||||
// Idioma finalmente aplicado a la petición (puede coincidir con `base` o no).
|
||||
effective: &'static LanguageIdentifier,
|
||||
|
|
@ -34,19 +35,25 @@ impl RequestLocale {
|
|||
///
|
||||
/// - [`LangNegotiation::Full`] determina el idioma en este orden:
|
||||
/// 1. Parámetro de *query* `?lang=...`, si existe y corresponde a un idioma soportado.
|
||||
/// 2. Idioma preferido del usuario ([`CurrentUser::language()`]), si tiene uno.
|
||||
/// 3. [`Locale::try_langid()`], si la aplicación tiene un idioma por defecto válido.
|
||||
/// 4. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`].
|
||||
/// 5. Idioma de respaldo.
|
||||
///
|
||||
/// - [`LangNegotiation::NoQuery`] descarta el uso del parámetro `?lang=...` y determina el
|
||||
/// idioma en este orden:
|
||||
/// 1. Idioma preferido del usuario ([`CurrentUser::language()`]), si tiene uno.
|
||||
/// 2. [`Locale::try_langid()`], si la aplicación tiene un idioma por defecto válido.
|
||||
/// 3. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`].
|
||||
/// 4. Idioma de respaldo.
|
||||
///
|
||||
/// - [`LangNegotiation::NoQuery`] descarta el uso del parámetro `?lang=...` y determina el
|
||||
/// idioma en este orden:
|
||||
/// 1. [`Locale::try_langid()`], si la aplicación tiene un idioma por defecto válido.
|
||||
/// 2. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`].
|
||||
/// 3. Idioma de respaldo.
|
||||
///
|
||||
/// - [`LangNegotiation::ConfigOnly`] sólo usa la configuración de la aplicación mediante
|
||||
/// [`Locale::default_langid()`], sin consultar la cabecera `Accept-Language` ni el parámetro
|
||||
/// `?lang`. Este modo también aplica el idioma de respaldo si es necesario.
|
||||
/// [`Locale::default_langid()`], sin consultar el idioma del usuario, la cabecera
|
||||
/// `Accept-Language` ni el parámetro `?lang`. Este modo también aplica el idioma de respaldo
|
||||
/// si es necesario.
|
||||
///
|
||||
/// El idioma del usuario se lee del [`CurrentUser`] que la extensión de autenticación inserta
|
||||
/// en las extensiones de la petición.
|
||||
///
|
||||
/// En todos los casos, el idioma resultante es siempre un [`LanguageIdentifier`] soportado por
|
||||
/// la aplicación y será el que PageTop utilice para renderizar la respuesta de la petición.
|
||||
|
|
@ -65,10 +72,14 @@ impl RequestLocale {
|
|||
Locale::default_langid()
|
||||
}
|
||||
global::LangNegotiation::Full | global::LangNegotiation::NoQuery => {
|
||||
if let Some(default) = Locale::try_langid() {
|
||||
default
|
||||
let user_language = request
|
||||
.and_then(|req| req.extension::<CurrentUser>())
|
||||
.and_then(CurrentUser::language);
|
||||
if let Some(langid) = user_language.or_else(Locale::try_langid) {
|
||||
langid
|
||||
} else {
|
||||
// Sin idioma por defecto, se evalúa la cabecera `Accept-Language`.
|
||||
// Sin idioma del usuario ni por defecto, se evalúa la cabecera
|
||||
// `Accept-Language`.
|
||||
request
|
||||
.and_then(|req| req.headers().get("Accept-Language"))
|
||||
.and_then(|value| value.to_str().ok())
|
||||
|
|
@ -147,9 +158,10 @@ impl RequestLocale {
|
|||
|
||||
/// Fuerza el idioma que se utilizará para las traducciones de esta petición.
|
||||
///
|
||||
/// Este método permite sustituir el idioma calculado (por configuración, cabecera, `?lang`,
|
||||
/// etc.) por otro idioma. Normalmente se usa cuando quieres que toda la respuesta se genere en
|
||||
/// un idioma concreto, independientemente de cómo se haya llegado a él.
|
||||
/// Este método permite sustituir el idioma calculado (por `?lang`, idioma del usuario,
|
||||
/// configuración, cabecera, etc.) por otro idioma. Normalmente se usa cuando quieres que toda
|
||||
/// la respuesta se genere en un idioma concreto, independientemente de cómo se haya llegado a
|
||||
/// él.
|
||||
#[inline]
|
||||
pub fn with_langid(&mut self, language: &impl LangId) -> &mut Self {
|
||||
self.effective = language.langid();
|
||||
|
|
@ -162,11 +174,12 @@ impl RequestLocale {
|
|||
/// El comportamiento depende de la estrategia configurada en [`LangNegotiation`]:
|
||||
///
|
||||
/// - En modo [`LangNegotiation::Full`] devuelve `true` cuando la respuesta se está generando en
|
||||
/// un idioma distinto del que la aplicación habría elegido automáticamente a partir de la
|
||||
/// configuración, el navegador y el idioma de respaldo. En la práctica suele significar que
|
||||
/// el usuario ha pedido expresamente otro idioma (por ejemplo, con `?lang=...`) o que se ha
|
||||
/// forzado con [`with_langid()`](Self::with_langid), y por tanto es recomendable propagar
|
||||
/// `lang=...` en los enlaces para mantener esa preferencia mientras se navega.
|
||||
/// un idioma distinto del que la aplicación habría elegido automáticamente a partir del
|
||||
/// idioma del usuario, la configuración, el navegador y el idioma de respaldo. En la práctica
|
||||
/// suele significar que el usuario ha pedido expresamente otro idioma (por ejemplo, con
|
||||
/// `?lang=...`) o que se ha forzado con [`with_langid()`](Self::with_langid), y por tanto es
|
||||
/// recomendable propagar `lang=...` en los enlaces para mantener esa preferencia mientras se
|
||||
/// navega.
|
||||
///
|
||||
/// - En modos [`LangNegotiation::NoQuery`] y [`LangNegotiation::ConfigOnly`] siempre devuelve
|
||||
/// `false`, ya que en estas estrategias la aplicación no utiliza el parámetro `?lang=...`
|
||||
|
|
|
|||
|
|
@ -286,13 +286,13 @@ impl Contextual for Page {
|
|||
self
|
||||
}
|
||||
|
||||
fn with_template(mut self, template: TemplateRef) -> Self {
|
||||
self.context.alter_template(template);
|
||||
fn with_theme(mut self, theme: ThemeRef) -> Self {
|
||||
self.context.alter_theme(theme);
|
||||
self
|
||||
}
|
||||
|
||||
fn with_theme(mut self, theme: ThemeRef) -> Self {
|
||||
self.context.alter_theme(theme);
|
||||
fn with_template(mut self, template: TemplateRef) -> Self {
|
||||
self.context.alter_template(template);
|
||||
self
|
||||
}
|
||||
|
||||
|
|
@ -336,14 +336,14 @@ impl Contextual for Page {
|
|||
self.context.timezone()
|
||||
}
|
||||
|
||||
fn template(&self) -> TemplateRef {
|
||||
self.context.template()
|
||||
}
|
||||
|
||||
fn theme(&self) -> ThemeRef {
|
||||
self.context.theme()
|
||||
}
|
||||
|
||||
fn template(&self) -> TemplateRef {
|
||||
self.context.template()
|
||||
}
|
||||
|
||||
fn param<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> {
|
||||
self.context.param(key)
|
||||
}
|
||||
|
|
|
|||
107
tests/auth.rs
107
tests/auth.rs
|
|
@ -4,7 +4,7 @@ use pagetop::prelude::*;
|
|||
|
||||
#[pagetop::test]
|
||||
async fn anonymous_reports_itself_correctly() {
|
||||
let user = CurrentUser::Anonymous;
|
||||
let user = CurrentUser::anonymous();
|
||||
assert!(user.is_anonymous());
|
||||
assert!(!user.is_authenticated());
|
||||
assert_eq!(user.id(), None);
|
||||
|
|
@ -14,11 +14,7 @@ async fn anonymous_reports_itself_correctly() {
|
|||
#[pagetop::test]
|
||||
async fn authenticated_reports_itself_correctly() {
|
||||
let madrid: Tz = "Europe/Madrid".parse().unwrap();
|
||||
let user = CurrentUser::Authenticated {
|
||||
id: 42,
|
||||
display_name: "Alice".to_owned(),
|
||||
timezone: Some(madrid),
|
||||
};
|
||||
let user = CurrentUser::authenticated(42, "Alice").with_timezone("Europe/Madrid");
|
||||
assert!(!user.is_anonymous());
|
||||
assert!(user.is_authenticated());
|
||||
assert_eq!(user.id(), Some(42));
|
||||
|
|
@ -28,14 +24,39 @@ async fn authenticated_reports_itself_correctly() {
|
|||
|
||||
#[pagetop::test]
|
||||
async fn authenticated_falls_back_to_default_timezone_when_none() {
|
||||
let user = CurrentUser::Authenticated {
|
||||
id: 42,
|
||||
display_name: "Alice".to_owned(),
|
||||
timezone: None,
|
||||
};
|
||||
let user = CurrentUser::authenticated(42, "Alice");
|
||||
assert_eq!(user.timezone(), Timezone::default_tz());
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn authenticated_discards_invalid_preferences() {
|
||||
let user = CurrentUser::authenticated(42, "Alice")
|
||||
.with_language("xx-XX")
|
||||
.with_timezone("Mars/Olympus")
|
||||
.with_theme("NotRegistered");
|
||||
assert_eq!(user.language(), None);
|
||||
assert_eq!(user.timezone(), Timezone::default_tz());
|
||||
assert!(user.theme().is_none());
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn anonymous_can_have_preferences() {
|
||||
let madrid: Tz = "Europe/Madrid".parse().unwrap();
|
||||
let user = CurrentUser::anonymous()
|
||||
.with_language("es-ES")
|
||||
.with_timezone("Europe/Madrid");
|
||||
assert!(user.is_anonymous());
|
||||
assert_eq!(user.language(), Locale::resolve("es-ES").as_option());
|
||||
assert_eq!(user.timezone(), madrid);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn authenticated_resolves_language_to_a_supported_one() {
|
||||
let user = CurrentUser::authenticated(42, "Alice").with_language("es");
|
||||
assert_eq!(user.language(), Locale::resolve("es").as_option());
|
||||
assert!(user.language().is_some());
|
||||
}
|
||||
|
||||
// **< Context::current_user() >********************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
|
|
@ -47,11 +68,7 @@ async fn current_user_defaults_to_anonymous() {
|
|||
#[pagetop::test]
|
||||
async fn current_user_propagates_from_request_extensions() {
|
||||
let req = web::test::TestRequest::get()
|
||||
.with_extension(CurrentUser::Authenticated {
|
||||
id: 7,
|
||||
display_name: "Bob".to_owned(),
|
||||
timezone: None,
|
||||
})
|
||||
.with_extension(CurrentUser::authenticated(7, "Bob"))
|
||||
.to_http_request();
|
||||
let cx = Context::new(req);
|
||||
let user = cx.current_user();
|
||||
|
|
@ -60,6 +77,52 @@ async fn current_user_propagates_from_request_extensions() {
|
|||
assert_eq!(user.display_name(), Some("Bob"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn user_language_sets_the_request_language() {
|
||||
let req = web::test::TestRequest::get()
|
||||
.header("Accept-Language", "en-US")
|
||||
.with_extension(CurrentUser::authenticated(7, "Bob").with_language("es-ES"))
|
||||
.to_http_request();
|
||||
let cx = Context::new(req);
|
||||
assert_eq!(cx.langid().to_string(), "es-ES");
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn lang_query_overrides_user_language() {
|
||||
let req = web::test::TestRequest::get()
|
||||
.uri("/?lang=en-US")
|
||||
.with_extension(CurrentUser::authenticated(7, "Bob").with_language("es-ES"))
|
||||
.to_http_request();
|
||||
let cx = Context::new(req);
|
||||
assert_eq!(cx.langid().to_string(), "en-US");
|
||||
}
|
||||
|
||||
struct UserTheme;
|
||||
|
||||
impl Extension for UserTheme {
|
||||
fn theme(&self) -> Option<ThemeRef> {
|
||||
Some(&UserTheme)
|
||||
}
|
||||
}
|
||||
|
||||
impl Theme for UserTheme {}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn user_theme_sets_the_context_theme() {
|
||||
let _ = Application::prepare(&UserTheme).await;
|
||||
let req = web::test::TestRequest::get()
|
||||
.with_extension(CurrentUser::authenticated(7, "Bob").with_theme("UserTheme"))
|
||||
.to_http_request();
|
||||
let cx = Context::new(req);
|
||||
assert_eq!(cx.theme().short_name(), "UserTheme");
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn context_uses_the_default_theme_without_user_theme() {
|
||||
let cx = Context::default();
|
||||
assert_eq!(cx.theme().short_name(), "Basic");
|
||||
}
|
||||
|
||||
// **< HttpRequest::extension() >*******************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
|
|
@ -71,11 +134,7 @@ async fn request_extension_returns_none_for_unknown_type() {
|
|||
#[pagetop::test]
|
||||
async fn request_extension_returns_injected_value() {
|
||||
let req = web::test::TestRequest::get()
|
||||
.with_extension(CurrentUser::Authenticated {
|
||||
id: 1,
|
||||
display_name: "Carol".to_owned(),
|
||||
timezone: None,
|
||||
})
|
||||
.with_extension(CurrentUser::authenticated(1, "Carol"))
|
||||
.to_http_request();
|
||||
|
||||
let user = req
|
||||
|
|
@ -91,11 +150,7 @@ async fn request_extension_returns_injected_value() {
|
|||
#[pagetop::test]
|
||||
async fn page_new_propagates_current_user_from_request_extensions() {
|
||||
let req = web::test::TestRequest::get()
|
||||
.with_extension(CurrentUser::Authenticated {
|
||||
id: 5,
|
||||
display_name: "Dave".to_owned(),
|
||||
timezone: None,
|
||||
})
|
||||
.with_extension(CurrentUser::authenticated(5, "Dave"))
|
||||
.to_http_request();
|
||||
let page = Page::new(req);
|
||||
let user = page.current_user();
|
||||
|
|
|
|||
101
tests/component_form_select_language.rs
Normal file
101
tests/component_form_select_language.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
use pagetop::prelude::*;
|
||||
|
||||
#[pagetop::test]
|
||||
async fn offers_every_supported_language_and_marks_the_selected_one() {
|
||||
let mut field = form::SelectLanguage::new()
|
||||
.with_name("language")
|
||||
.with_selected("es-ES");
|
||||
let html = field.render(&mut Context::default()).await.into_string();
|
||||
|
||||
for (langid, _) in Locale::supported_languages() {
|
||||
assert!(html.contains(&format!(r#"value="{langid}""#)));
|
||||
}
|
||||
assert!(html.contains(r#"<option value="es-ES" selected>"#));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn optional_field_offers_the_site_language_first() {
|
||||
let mut field = form::SelectLanguage::new().with_selected("es-ES");
|
||||
let html = field.render(&mut Context::default()).await.into_string();
|
||||
|
||||
let site = html.find(r#"<option value="">Use the site language: "#);
|
||||
let first_language = html.find(r#"<option value="en"#);
|
||||
assert!(site.is_some());
|
||||
assert!(site < first_language);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn optional_field_selects_the_site_language_when_nothing_is_selected() {
|
||||
let mut field = form::SelectLanguage::new();
|
||||
let html = field.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(html.contains(r#"<option value="" selected>Use the site language: "#));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn required_field_asks_to_choose_when_nothing_is_selected() {
|
||||
let mut field = form::SelectLanguage::new().with_required(true);
|
||||
let html = field.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(html.contains(r#"<option value="" selected>Choose a language...</option>"#));
|
||||
assert!(!html.contains("Use the site language"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn required_field_has_no_empty_option_when_a_language_is_selected() {
|
||||
let mut field = form::SelectLanguage::new()
|
||||
.with_required(true)
|
||||
.with_selected("es-ES");
|
||||
let html = field.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(!html.contains(r#"value="""#));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn language_aliases_select_their_canonical_language() {
|
||||
for alias in ["es", "es-es", "ES-ES"] {
|
||||
let mut field = form::SelectLanguage::new().with_selected(alias);
|
||||
let html = field.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(html.contains(r#"<option value="es-ES" selected>"#));
|
||||
assert!(html.contains(r#"<option value="">Use the site language: "#));
|
||||
}
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn unknown_selected_value_falls_back_to_the_empty_option() {
|
||||
let mut optional = form::SelectLanguage::new().with_selected("xx-XX");
|
||||
let html = optional.render(&mut Context::default()).await.into_string();
|
||||
assert!(html.contains(r#"<option value="" selected>Use the site language: "#));
|
||||
|
||||
let mut required = form::SelectLanguage::new()
|
||||
.with_required(true)
|
||||
.with_selected("xx-XX");
|
||||
let html = required.render(&mut Context::default()).await.into_string();
|
||||
assert!(html.contains(r#"<option value="" selected>Choose a language...</option>"#));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn languages_are_sorted_by_their_translated_name() {
|
||||
let mut field = form::SelectLanguage::new();
|
||||
|
||||
let mut cx = Context::default().with_langid(&Locale::resolve("es-ES"));
|
||||
let html = field.render(&mut cx).await.into_string();
|
||||
let spanish = html
|
||||
.find(">Español (España)</option>")
|
||||
.expect("Spanish name");
|
||||
let english = html
|
||||
.find(">Inglés (Estados Unidos)</option>")
|
||||
.expect("English name");
|
||||
assert!(spanish < english);
|
||||
|
||||
let mut cx = Context::default().with_langid(&Locale::resolve("en-US"));
|
||||
let html = field.render(&mut cx).await.into_string();
|
||||
let spanish = html
|
||||
.find(">Spanish (Spain)</option>")
|
||||
.expect("Spanish name");
|
||||
let english = html
|
||||
.find(">English (United States)</option>")
|
||||
.expect("English name");
|
||||
assert!(english < spanish);
|
||||
}
|
||||
64
tests/component_form_select_theme.rs
Normal file
64
tests/component_form_select_theme.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// With more than one enabled theme. The single-theme case lives in its own test binary, because the
|
||||
// theme registry is global to the process.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
struct Aurora;
|
||||
|
||||
impl Extension for Aurora {
|
||||
fn name(&self) -> Lc {
|
||||
Lc::n("Aurora")
|
||||
}
|
||||
|
||||
fn theme(&self) -> Option<ThemeRef> {
|
||||
Some(&Aurora)
|
||||
}
|
||||
}
|
||||
|
||||
impl Theme for Aurora {}
|
||||
|
||||
async fn setup() {
|
||||
let _ = Application::prepare(&Aurora).await;
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn themes_are_sorted_by_name_and_the_selected_one_is_marked() {
|
||||
setup().await;
|
||||
let mut field = form::SelectTheme::new()
|
||||
.with_name("theme")
|
||||
.with_selected("Basic");
|
||||
let html = field.render(&mut Context::default()).await.into_string();
|
||||
|
||||
let aurora = html.find(r#"<option value="Aurora""#).expect("Aurora");
|
||||
let basic = html
|
||||
.find(r#"<option value="Basic" selected>"#)
|
||||
.expect("Basic");
|
||||
assert!(aurora < basic);
|
||||
assert!(!html.contains("disabled"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn optional_field_offers_the_site_theme_first() {
|
||||
setup().await;
|
||||
let mut field = form::SelectTheme::new();
|
||||
let html = field.render(&mut Context::default()).await.into_string();
|
||||
|
||||
let site = html.find(r#"<option value="" selected>Use the site theme: "#);
|
||||
let first_theme = html.find(r#"<option value="Aurora""#);
|
||||
assert!(site.is_some());
|
||||
assert!(site < first_theme);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn required_field_asks_to_choose_only_when_nothing_valid_is_selected() {
|
||||
setup().await;
|
||||
let mut empty = form::SelectTheme::new().with_required(true);
|
||||
let html = empty.render(&mut Context::default()).await.into_string();
|
||||
assert!(html.contains(r#"<option value="" selected>Choose a theme...</option>"#));
|
||||
|
||||
let mut chosen = form::SelectTheme::new()
|
||||
.with_required(true)
|
||||
.with_selected("Aurora");
|
||||
let html = chosen.render(&mut Context::default()).await.into_string();
|
||||
assert!(!html.contains(r#"value="""#));
|
||||
}
|
||||
14
tests/component_form_select_theme_single.rs
Normal file
14
tests/component_form_select_theme_single.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// With only the default theme enabled. See `component_form_select_theme.rs` for several themes.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
#[pagetop::test]
|
||||
async fn single_theme_is_shown_disabled_and_selected() {
|
||||
Application::new().await;
|
||||
let mut field = form::SelectTheme::new().with_name("theme");
|
||||
let html = field.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(html.contains("disabled"));
|
||||
assert!(html.contains(r#"<option value="Basic" selected>"#));
|
||||
assert!(!html.contains(r#"value="""#));
|
||||
}
|
||||
63
tests/component_form_select_timezone.rs
Normal file
63
tests/component_form_select_timezone.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use pagetop::prelude::*;
|
||||
|
||||
#[pagetop::test]
|
||||
async fn groups_zones_by_region_and_marks_the_selected_one() {
|
||||
let mut field = form::SelectTimezone::new()
|
||||
.with_name("timezone")
|
||||
.with_selected("Europe/Madrid");
|
||||
let html = field.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(html.contains(r#"<optgroup label="Europe">"#));
|
||||
assert!(html.contains(r#"<option value="Europe/Madrid" selected>"#));
|
||||
assert!(html.contains(r#"value="Etc/UTC""#));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn region_labels_are_translated() {
|
||||
let mut field = form::SelectTimezone::new();
|
||||
let mut cx = Context::default().with_langid(&Locale::resolve("es-ES"));
|
||||
let html = field.render(&mut cx).await.into_string();
|
||||
|
||||
assert!(html.contains(r#"<optgroup label="Europa">"#));
|
||||
assert!(html.contains(r#"<optgroup label="Otras">"#));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn legacy_and_fixed_offset_zones_are_not_offered() {
|
||||
let mut field = form::SelectTimezone::new();
|
||||
let html = field.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(!html.contains(r#"value="US/Eastern""#));
|
||||
assert!(!html.contains(r#"value="Japan""#));
|
||||
assert!(!html.contains(r#"value="Etc/GMT+1""#));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn optional_field_offers_the_site_time_zone_first() {
|
||||
let mut field = form::SelectTimezone::new();
|
||||
let html = field.render(&mut Context::default()).await.into_string();
|
||||
|
||||
let site = html.find(r#"<option value="" selected>Use the site time zone: "#);
|
||||
let first_group = html.find("<optgroup");
|
||||
assert!(site.is_some());
|
||||
assert!(site < first_group);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn required_field_asks_to_choose_only_when_nothing_valid_is_selected() {
|
||||
let mut empty = form::SelectTimezone::new().with_required(true);
|
||||
let html = empty.render(&mut Context::default()).await.into_string();
|
||||
assert!(html.contains(r#"<option value="" selected>Choose a time zone...</option>"#));
|
||||
|
||||
let mut unknown = form::SelectTimezone::new()
|
||||
.with_required(true)
|
||||
.with_selected("Mars/Olympus");
|
||||
let html = unknown.render(&mut Context::default()).await.into_string();
|
||||
assert!(html.contains(r#"<option value="" selected>Choose a time zone...</option>"#));
|
||||
|
||||
let mut chosen = form::SelectTimezone::new()
|
||||
.with_required(true)
|
||||
.with_selected("Europe/Madrid");
|
||||
let html = chosen.render(&mut Context::default()).await.into_string();
|
||||
assert!(!html.contains(r#"value="""#));
|
||||
}
|
||||
|
|
@ -23,11 +23,7 @@ async fn resolve_uses_the_timezone_already_resolved_by_the_authenticated_user()
|
|||
|
||||
let madrid: Tz = "Europe/Madrid".parse().unwrap();
|
||||
let req = web::test::TestRequest::get()
|
||||
.with_extension(CurrentUser::Authenticated {
|
||||
id: 1,
|
||||
display_name: "Alice".to_owned(),
|
||||
timezone: Some(madrid),
|
||||
})
|
||||
.with_extension(CurrentUser::authenticated(1, "Alice").with_timezone("Europe/Madrid"))
|
||||
.to_http_request();
|
||||
let cx = Context::new(req);
|
||||
assert_eq!(cx.timezone(), madrid);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue