✨ (user): Ordena alfabéticamente grupos de permisos
Añade `Lc::collation_key()` en PageTop para obtener una clave de ordenación no sensible a mayúsculas y acentos, con `ñ` como letra propia entre `n` y `o` en español.
This commit is contained in:
parent
93cd3ba6c5
commit
dbdaeb6151
7 changed files with 144 additions and 18 deletions
|
|
@ -14,11 +14,13 @@ use crate::permission::{self, UserPermission};
|
|||
pub(crate) async fn list_get(request: HttpRequest) -> Result<Response, ErrorPage> {
|
||||
require_permission(&request, &UserPermission::AdminPermissions)?;
|
||||
|
||||
let mut page = Page::admin(request);
|
||||
|
||||
let registry = permission::registry();
|
||||
let title = Lc::t("title-admin-permissions", &LOCALES_USER);
|
||||
let mut content = frame(title.clone());
|
||||
|
||||
for (group, group_label) in registry.groups() {
|
||||
for (group, group_label) in registry.groups_sorted(page.context()) {
|
||||
let items: Vec<(CowStr, Lc)> = registry
|
||||
.by_group(group)
|
||||
.map(|permission| (permission.key(), permission.label()))
|
||||
|
|
@ -41,6 +43,10 @@ pub(crate) async fn list_get(request: HttpRequest) -> Result<Response, ErrorPage
|
|||
));
|
||||
}
|
||||
|
||||
let mut page = Page::admin(request).with_title(title).with_child(content);
|
||||
Ok(page.render().await.into_response())
|
||||
Ok(page
|
||||
.with_title(title)
|
||||
.with_child(content)
|
||||
.render()
|
||||
.await
|
||||
.into_response())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ pub(crate) async fn new_post(
|
|||
}
|
||||
}
|
||||
|
||||
// **< edit_get / edit_post >************************************************************************
|
||||
// **< edit_get / edit_post >***********************************************************************
|
||||
|
||||
/// GET /admin/user/roles/{id}/edit - Formulario de edición de rol.
|
||||
pub(crate) async fn edit_get(
|
||||
|
|
@ -286,9 +286,9 @@ pub(crate) async fn view_get(
|
|||
Ok(keys) => keys,
|
||||
Err(_) => return Err(ErrorPage::InternalError(Some(request))),
|
||||
};
|
||||
let groups = build_permission_groups(&selected);
|
||||
|
||||
let mut page = Page::admin(request);
|
||||
|
||||
let groups = build_permission_groups(&selected, page.context());
|
||||
let back_href = waypoint.or(page.context().route(ADMIN_ROLES_PATH));
|
||||
let details_block = role_view_details(&role, page.context()).await;
|
||||
|
||||
|
|
@ -469,13 +469,13 @@ pub(crate) async fn delete_confirm_get(
|
|||
Ok(HtmxResponse::new(button.render(&mut cx).await).into_response())
|
||||
}
|
||||
|
||||
// **< permissions_get / permissions_post >**********************************************************
|
||||
// **< permissions_get / permissions_post >*********************************************************
|
||||
|
||||
fn build_permission_groups(selected: &[String]) -> PermissionGroups {
|
||||
fn build_permission_groups(selected: &[String], cx: &Context) -> PermissionGroups {
|
||||
let registry = permission::registry();
|
||||
registry
|
||||
.groups()
|
||||
.iter()
|
||||
.groups_sorted(cx)
|
||||
.into_iter()
|
||||
.map(|(group, group_label)| {
|
||||
let perms = registry
|
||||
.by_group(group)
|
||||
|
|
@ -507,9 +507,9 @@ pub(crate) async fn permissions_get(
|
|||
Ok(keys) => keys,
|
||||
Err(_) => return Err(ErrorPage::InternalError(Some(request))),
|
||||
};
|
||||
let groups = build_permission_groups(&selected);
|
||||
|
||||
let mut page = Page::admin(request);
|
||||
|
||||
let groups = build_permission_groups(&selected, page.context());
|
||||
let back_href = waypoint.or(page.context().route(ADMIN_ROLES_PATH));
|
||||
|
||||
let title = Lc::t("title-admin-role-permissions", &LOCALES_USER);
|
||||
|
|
@ -560,8 +560,9 @@ pub(crate) async fn permissions_post(
|
|||
Ok(Redirect::see_other(target).into_response())
|
||||
}
|
||||
Err(err) => {
|
||||
let groups = build_permission_groups(&form.permission_keys);
|
||||
let mut page = Page::admin(request);
|
||||
|
||||
let groups = build_permission_groups(&form.permission_keys, page.context());
|
||||
let back_href = waypoint.or(page.context().route(ADMIN_ROLES_PATH));
|
||||
let form_component = RolePermissionsForm::new()
|
||||
.with_role_id(id)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# **< Group: Users >**
|
||||
|
||||
group-users = Users
|
||||
group-users = User management
|
||||
|
||||
perm-login = Sign in
|
||||
perm-register = Register a new account
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# **< Grupo: Usuarios >**
|
||||
|
||||
group-users = Usuarios
|
||||
group-users = Gestión de usuarios
|
||||
|
||||
perm-login = Iniciar sesión
|
||||
perm-register = Registrar una cuenta nueva
|
||||
|
|
|
|||
|
|
@ -68,9 +68,10 @@ impl ActionDispatcher for DeclarePermissions {}
|
|||
|
||||
/// Catálogo mutable de permisos, construido durante la fase de inicialización.
|
||||
///
|
||||
/// Un `Vec` basta: el catálogo se construye una sola vez con un puñado de entradas y se recorre
|
||||
/// entero en la UI de administración, así que conserva el orden de registro sin estructuras
|
||||
/// adicionales y sin el coste de mantenerlas sincronizadas.
|
||||
/// Se construye una sola vez con un conjunto de entradas que conserva el orden de registro sin
|
||||
/// estructuras adicionales. La UI de administración no usa ese orden de registro. Llama a
|
||||
/// [`groups_sorted()`](Self::groups_sorted), que lo reordena alfabéticamente para cada petición
|
||||
/// según el idioma activo.
|
||||
#[derive(Default)]
|
||||
pub struct PermissionRegistry {
|
||||
permissions: Vec<PermissionRef>,
|
||||
|
|
@ -105,10 +106,28 @@ impl PermissionRegistry {
|
|||
self.permissions.iter().copied()
|
||||
}
|
||||
|
||||
/// Grupos en orden de primer registro, sin traducir ni ordenar alfabéticamente.
|
||||
///
|
||||
/// Listado de bajo nivel sobre el que se apoya [`groups_sorted()`](Self::groups_sorted); úsalo
|
||||
/// en su lugar sólo cuando el orden de registro sea justo lo que se necesita (por ejemplo, para
|
||||
/// inspeccionar el catálogo en pruebas).
|
||||
pub fn groups(&self) -> &[(&'static str, Lc)] {
|
||||
&self.groups
|
||||
}
|
||||
|
||||
/// Grupos ordenados alfabéticamente por su título traducido al idioma del contexto, no sensible
|
||||
/// a mayúsculas y, en general, a los acentos (ver [`Lc::collation_key()`] para el criterio
|
||||
/// exacto y sus límites).
|
||||
pub fn groups_sorted(&self, cx: &Context) -> Vec<&(&'static str, Lc)> {
|
||||
let mut groups: Vec<_> = self
|
||||
.groups
|
||||
.iter()
|
||||
.map(|g| (g.1.collation_key(cx), g))
|
||||
.collect();
|
||||
groups.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
groups.into_iter().map(|(_, g)| g).collect()
|
||||
}
|
||||
|
||||
pub fn by_group<'a>(&'a self, group: &'a str) -> impl Iterator<Item = PermissionRef> + 'a {
|
||||
self.permissions
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -247,4 +247,56 @@ impl Lc {
|
|||
_ => PreEscaped(self.lookup(language).unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calcula una clave de ordenación alfabética del texto traducido, no sensible a mayúsculas y,
|
||||
/// en general, a los acentos.
|
||||
///
|
||||
/// Permite ordenar listados de textos traducidos (títulos, etiquetas, etc.) de forma natural
|
||||
/// para quien lee. Reduce a minúsculas y a su letra base las vocales acentuadas y `ç`, criterio
|
||||
/// común a la mayoría de idiomas que las usan.
|
||||
///
|
||||
/// Tiene en cuenta como caso especial la letra `ñ` en español. Se conserva como letra propia
|
||||
/// entre `n` y `o`. Por ahora no distingue entre idiomas; esta regla, específica del español,
|
||||
/// se aplica también a textos en cualquier otro idioma soportado. No supone un problema en la
|
||||
/// práctica porque `ñ` no aparece en el resto de idiomas salvo en préstamos puntuales, pero no
|
||||
/// es el criterio nativo de otras lenguas (en francés, italiano, alemán o portugués, por
|
||||
/// ejemplo, `ñ` no es una letra propia del alfabeto).
|
||||
///
|
||||
/// El resultado es una clave a comparar con [`str::cmp()`] o [`String::cmp()`], no un texto
|
||||
/// para mostrar.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let mut groups = vec![
|
||||
/// Lc::n("Ñu"),
|
||||
/// Lc::n("Nube"),
|
||||
/// Lc::n("Oso"),
|
||||
/// Lc::n("Café"),
|
||||
/// Lc::n("Anual"),
|
||||
/// Lc::n("Año"),
|
||||
/// ];
|
||||
/// groups.sort_by_key(|g| g.collation_key(&Locale::default()));
|
||||
///
|
||||
/// let order: Vec<_> = groups.iter().map(|g| g.get().unwrap()).collect();
|
||||
/// assert_eq!(order, vec!["Anual", "Año", "Café", "Nube", "Ñu", "Oso"]);
|
||||
/// ```
|
||||
pub fn collation_key(&self, language: &impl LangId) -> String {
|
||||
let text = self.lookup(language).unwrap_or_default();
|
||||
let mut key = String::with_capacity(text.len());
|
||||
for c in text.chars().flat_map(char::to_lowercase) {
|
||||
match c {
|
||||
'á' | 'à' | 'ä' | 'â' => key.push('a'),
|
||||
'é' | 'è' | 'ë' | 'ê' => key.push('e'),
|
||||
'í' | 'ì' | 'ï' | 'î' => key.push('i'),
|
||||
'ó' | 'ò' | 'ö' | 'ô' => key.push('o'),
|
||||
'ú' | 'ù' | 'ü' | 'û' => key.push('u'),
|
||||
'ç' => key.push('c'),
|
||||
'ñ' => key.push_str("n\u{10ffff}"),
|
||||
_ => key.push(c),
|
||||
}
|
||||
}
|
||||
key
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,3 +85,51 @@ async fn translated_text_is_not_escaped_when_rendered_as_markup() {
|
|||
let markup = lc.using(&Locale::resolve("en-US"));
|
||||
assert_eq!(markup.into_string(), "Hello world!");
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn collation_key_is_case_and_accent_insensitive() {
|
||||
setup().await;
|
||||
|
||||
let lower = Lc::n("café").collation_key(&Locale::default());
|
||||
let upper = Lc::n("CAFÉ").collation_key(&Locale::default());
|
||||
assert_eq!(lower, upper);
|
||||
}
|
||||
|
||||
// `ñ` sorts as its own letter (Spanish/RAE order), between "n..." and "o...", not merged with `n`
|
||||
// like the other accented letters.
|
||||
#[pagetop::test]
|
||||
async fn collation_key_orders_n_tilde_like_spanish() {
|
||||
setup().await;
|
||||
|
||||
let mut words = vec![Lc::n("Ñu"), Lc::n("Nube"), Lc::n("Oso"), Lc::n("Café")];
|
||||
words.sort_by_key(|w| w.collation_key(&Locale::default()));
|
||||
|
||||
let sorted: Vec<_> = words.iter().map(|w| w.get().unwrap()).collect();
|
||||
assert_eq!(sorted, vec!["Café", "Nube", "Ñu", "Oso"]);
|
||||
}
|
||||
|
||||
// Minimal pair sharing the same prefix ("an-"/"añ-"): the comparison is decided by the second
|
||||
// letter alone (`n` < `ñ`), regardless of how the words continue afterwards ("anual" has more
|
||||
// letters after the `n` than "año" has after the `ñ`, and still sorts first).
|
||||
#[pagetop::test]
|
||||
async fn collation_key_orders_n_tilde_before_further_letters() {
|
||||
setup().await;
|
||||
|
||||
let mut words = [Lc::n("Año"), Lc::n("Anzuelo"), Lc::n("Anual")];
|
||||
words.sort_by_key(|w| w.collation_key(&Locale::default()));
|
||||
|
||||
let sorted: Vec<_> = words.iter().map(|w| w.get().unwrap()).collect();
|
||||
assert_eq!(sorted, vec!["Anual", "Anzuelo", "Año"]);
|
||||
}
|
||||
|
||||
// `collation_key()` sorts the *resolved* translation, not the raw key, so the same `Lc` yields a
|
||||
// different key depending on the language passed in.
|
||||
#[pagetop::test]
|
||||
async fn collation_key_sorts_the_resolved_translation() {
|
||||
setup().await;
|
||||
|
||||
let lc = Lc::l("test_hello_world");
|
||||
let key_en = lc.collation_key(&Locale::resolve("en-US"));
|
||||
let key_es = lc.collation_key(&Locale::resolve("es-ES"));
|
||||
assert_ne!(key_en, key_es);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue