✨ (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:
Manuel Cillero 2026-10-04 23:09:07 +02:00
parent 35f2b66161
commit 70744a51a5
25 changed files with 1162 additions and 190 deletions

View file

@ -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(())

View file

@ -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;

View 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
}
}

View 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
}
}

View 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
}
}

View file

@ -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>()

View file

@ -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:

View file

@ -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 {

View file

@ -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};

View file

@ -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
}

View file

@ -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()`].

View file

@ -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(&region) || (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
}
}

View file

@ -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,
}

View file

@ -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.

View file

@ -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 }

View file

@ -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 }

View file

@ -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"
);
}
}
}

View file

@ -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=...`

View file

@ -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)
}

View file

@ -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();

View 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);
}

View 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="""#));
}

View 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="""#));
}

View 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="""#));
}

View file

@ -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);