From 807a5e3f00ee8d4e5bf3e0b0786f91d1b5824ecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 17 Nov 2023 15:51:07 +0000 Subject: [PATCH 01/58] Include the code in the list of locales * Additionally, consider as supported all the locales known to systemd. * Rename LabelsForLocales to ListLocales. --- rust/Cargo.lock | 1 + rust/agama-dbus-server/src/locale.rs | 62 ++++++++++++++++++---------- rust/agama-lib/src/proxies.rs | 4 +- rust/agama-locale-data/Cargo.toml | 1 + rust/agama-locale-data/src/lib.rs | 24 ++--------- rust/agama-locale-data/src/locale.rs | 30 ++++++++++++++ web/src/client/language.js | 11 +++-- web/src/client/language.test.js | 7 +++- 8 files changed, 88 insertions(+), 52 deletions(-) create mode 100644 rust/agama-locale-data/src/locale.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index bb72b0de76..5f5bde8ad7 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -100,6 +100,7 @@ dependencies = [ "quick-xml", "regex", "serde", + "thiserror", ] [[package]] diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index 54e5bcb2cc..b0387e783d 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -1,4 +1,5 @@ use crate::error::Error; +use agama_locale_data::LocaleCode; use anyhow::Context; use std::{fs::read_dir, process::Command}; use zbus::{dbus_interface, Connection}; @@ -13,24 +14,33 @@ pub struct Locale { #[dbus_interface(name = "org.opensuse.Agama1.Locale")] impl Locale { - /// Get labels for locales. The first pair is english language and territory - /// and second one is localized one to target language from locale. + /// Gets the supported locales information. + /// + /// Each element of the has these parts: + /// + /// * The locale code (e.g., "es_ES.UTF-8"). + /// * A pair composed by the language and the territory names in english + /// (e.g. ("Spanish", "Spain")). + /// * A pair composed by the language and the territory names in its own language + /// (e.g. ("Español", "España")). /// - // Can be `async` as well. // NOTE: check how often it is used and if often, it can be easily cached - fn labels_for_locales(&self) -> Result, Error> { + fn list_locales(&self) -> Result, Error> { const DEFAULT_LANG: &str = "en"; - let mut res = Vec::with_capacity(self.supported_locales.len()); + let mut result = Vec::with_capacity(self.supported_locales.len()); let languages = agama_locale_data::get_languages()?; let territories = agama_locale_data::get_territories()?; - for locale in self.supported_locales.as_slice() { - let (loc_language, loc_territory) = agama_locale_data::parse_locale(locale.as_str())?; + for code in self.supported_locales.as_slice() { + let Ok(loc) = TryInto::::try_into(code.as_str()) else { + log::debug!("Ignoring locale code {}", &code); + continue; + }; let language = languages - .find_by_id(loc_language) + .find_by_id(&loc.language) .context("language for passed locale not found")?; let territory = territories - .find_by_id(loc_territory) + .find_by_id(&loc.territory) .context("territory for passed locale not found")?; let default_ret = ( @@ -53,10 +63,10 @@ impl Locale { .name_for(language.id.as_str()) .context("missing native label for territory")?, ); - res.push((default_ret, localized_ret)); + result.push((code.clone(), default_ret, localized_ret)); } - Ok(res) + Ok(result) } #[dbus_interface(property)] @@ -219,20 +229,30 @@ impl Locale { } impl Locale { - pub fn new() -> Self { - Self { - locales: vec!["en_US.UTF-8".to_string()], - keymap: "us".to_string(), - timezone_id: "America/Los_Angeles".to_string(), - supported_locales: vec!["en_US.UTF-8".to_string()], - ui_locale: "en".to_string(), - } + pub fn from_system() -> Result { + let result = Command::new("/usr/bin/localectl") + .args(["list-locales"]) + .output() + .context("Failed to get the list of locales")?; + let output = + String::from_utf8(result.stdout).context("Invalid UTF-8 sequence from list-locales")?; + let supported: Vec = output.lines().map(|s| s.to_string()).collect(); + Ok(Self { + supported_locales: supported, + ..Default::default() + }) } } impl Default for Locale { fn default() -> Self { - Self::new() + Self { + locales: vec!["en_US.UTF-8".to_string(), "es_ES.UTF-8".to_string()], + keymap: "us".to_string(), + timezone_id: "America/Los_Angeles".to_string(), + supported_locales: vec!["en_US.UTF-8".to_string(), "es_ES.UTF-8".to_string()], + ui_locale: "en".to_string(), + } } } @@ -242,7 +262,7 @@ pub async fn export_dbus_objects( const PATH: &str = "/org/opensuse/Agama1/Locale"; // When serving, request the service name _after_ exposing the main object - let locale = Locale::new(); + let locale = Locale::from_system()?; connection.object_server().at(PATH, locale).await?; Ok(()) diff --git a/rust/agama-lib/src/proxies.rs b/rust/agama-lib/src/proxies.rs index cf4b175e93..d4d049e597 100644 --- a/rust/agama-lib/src/proxies.rs +++ b/rust/agama-lib/src/proxies.rs @@ -80,8 +80,8 @@ trait Locale { /// Commit method fn commit(&self) -> zbus::Result<()>; - /// LabelsForLocales method - fn labels_for_locales(&self) -> zbus::Result>; + /// ListLocales method + fn list_locales(&self) -> zbus::Result>; /// ListTimezones method fn list_timezones(&self, locale: &str) -> zbus::Result>; diff --git a/rust/agama-locale-data/Cargo.toml b/rust/agama-locale-data/Cargo.toml index 46380041c5..ca9eda6ba3 100644 --- a/rust/agama-locale-data/Cargo.toml +++ b/rust/agama-locale-data/Cargo.toml @@ -12,3 +12,4 @@ quick-xml = { version = "0.28.2", features = ["serialize"] } flate2 = "1.0.25" chrono-tz = "0.8.2" regex = "1" +thiserror = "1.0.50" diff --git a/rust/agama-locale-data/src/lib.rs b/rust/agama-locale-data/src/lib.rs index 0fe0803915..ff85fcb1bd 100644 --- a/rust/agama-locale-data/src/lib.rs +++ b/rust/agama-locale-data/src/lib.rs @@ -1,7 +1,6 @@ use anyhow::Context; use flate2::bufread::GzDecoder; use quick_xml::de::Deserializer; -use regex::Regex; use serde::Deserialize; use std::fs::File; use std::io::BufRead; @@ -10,12 +9,15 @@ use std::process::Command; pub mod deprecated_timezones; pub mod language; +mod locale; pub mod localization; pub mod ranked; pub mod territory; pub mod timezone_part; pub mod xkeyboard; +pub use locale::{InvalidLocaleCode, LocaleCode}; + fn file_reader(file_path: &str) -> anyhow::Result { let file = File::open(file_path) .with_context(|| format!("Failed to read langtable-data ({})", file_path))?; @@ -55,26 +57,6 @@ pub fn get_key_maps() -> anyhow::Result> { Ok(ret) } -/// Parses given locale to language and territory part -/// -/// /// ## Examples -/// -/// ``` -/// let result = agama_locale_data::parse_locale("en_US.UTF-8").unwrap(); -/// assert_eq!(result.0, "en"); -/// assert_eq!(result.1, "US") -/// ``` -pub fn parse_locale(locale: &str) -> anyhow::Result<(&str, &str)> { - let locale_regexp: Regex = Regex::new(r"^([[:alpha:]]+)_([[:alpha:]]+)").unwrap(); - let captures = locale_regexp - .captures(locale) - .context("Failed to parse locale")?; - Ok(( - captures.get(1).unwrap().as_str(), - captures.get(2).unwrap().as_str(), - )) -} - /// Returns struct which contain list of known languages pub fn get_languages() -> anyhow::Result { const FILE_PATH: &str = "/usr/share/langtable/data/languages.xml.gz"; diff --git a/rust/agama-locale-data/src/locale.rs b/rust/agama-locale-data/src/locale.rs new file mode 100644 index 0000000000..b1cce89ac8 --- /dev/null +++ b/rust/agama-locale-data/src/locale.rs @@ -0,0 +1,30 @@ +use regex::Regex; +use thiserror::Error; + +pub struct LocaleCode { + // ISO-639 + pub language: String, + // ISO-3166 + pub territory: String, + // encoding: String, +} + +#[derive(Error, Debug)] +#[error("Not a valid locale string: {0}")] +pub struct InvalidLocaleCode(String); + +impl TryFrom<&str> for LocaleCode { + type Error = InvalidLocaleCode; + + fn try_from(value: &str) -> Result { + let locale_regexp: Regex = Regex::new(r"^([[:alpha:]]+)_([[:alpha:]]+)").unwrap(); + let captures = locale_regexp + .captures(value) + .ok_or_else(|| InvalidLocaleCode(value.to_string()))?; + + Ok(Self { + language: captures.get(1).unwrap().as_str().to_string(), + territory: captures.get(2).unwrap().as_str().to_string(), + }) + } +} diff --git a/web/src/client/language.js b/web/src/client/language.js index e40ab1164c..8ada871498 100644 --- a/web/src/client/language.js +++ b/web/src/client/language.js @@ -50,12 +50,11 @@ class LanguageClient { */ async getLanguages() { const proxy = await this.client.proxy(LANGUAGE_IFACE); - const locales = proxy.SupportedLocales; - const labels = await proxy.LabelsForLocales(); - return locales.map((locale, index) => { - // labels structure is [[en_lang, en_territory], [native_lang, native_territory]] - const [[en_lang,], [,]] = labels[index]; - return { id: locale, name: en_lang }; + const locales = await proxy.ListLocales(); + return locales.map(locale => { + const [id, [language, territory], [,]] = locale; + const name = `${language} (${territory})`; + return { id, name }; }); } diff --git a/web/src/client/language.test.js b/web/src/client/language.test.js index ebe05fb712..6a33eb638a 100644 --- a/web/src/client/language.test.js +++ b/web/src/client/language.test.js @@ -30,8 +30,11 @@ jest.mock("./dbus"); const langProxy = { wait: jest.fn(), SupportedLocales: ["es_ES.UTF-8", "en_US.UTF-8"], - LabelsForLocales: jest.fn().mockResolvedValue( - [[["Spanish", "Spain"], ["Español", "España"]], [['English', 'United States'], ['English', 'United States']]] + ListLocales: jest.fn().mockResolvedValue( + [ + ["es_ES.UTF-8", ["Spanish", "Spain"], ["Español", "España"]], + ["en_US.UTF-8", ['English', 'United States'], ['English', 'United States']] + ] ), }; From fdb0df8c4bcbd81936a8576271d1ceb53955660c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 17 Nov 2023 17:18:10 +0000 Subject: [PATCH 02/58] Simplify ListLocales API * Include the code, the language and the territory name. * Use the UILocale for translations. --- rust/agama-dbus-server/src/locale.rs | 49 ++++++++++++++-------------- rust/agama-lib/src/proxies.rs | 2 +- web/src/client/language.js | 5 +-- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index b0387e783d..6d242b9ff0 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -25,7 +25,7 @@ impl Locale { /// (e.g. ("Español", "España")). /// // NOTE: check how often it is used and if often, it can be easily cached - fn list_locales(&self) -> Result, Error> { + fn list_locales(&self) -> Result, Error> { const DEFAULT_LANG: &str = "en"; let mut result = Vec::with_capacity(self.supported_locales.len()); let languages = agama_locale_data::get_languages()?; @@ -36,34 +36,33 @@ impl Locale { continue; }; + let ui_language = self + .ui_locale + .split_once("_") + .map(|(l, _)| l) + .unwrap_or(DEFAULT_LANG); + let language = languages .find_by_id(&loc.language) - .context("language for passed locale not found")?; + .context("language not found")?; + + let names = &language.names; + let language_label = names + .name_for(&ui_language) + .or_else(|| names.name_for(DEFAULT_LANG)) + .unwrap_or(language.id.to_string()); + let territory = territories .find_by_id(&loc.territory) - .context("territory for passed locale not found")?; - - let default_ret = ( - language - .names - .name_for(DEFAULT_LANG) - .context("missing default translation for language")?, - territory - .names - .name_for(DEFAULT_LANG) - .context("missing default translation for territory")?, - ); - let localized_ret = ( - language - .names - .name_for(language.id.as_str()) - .context("missing native label for language")?, - territory - .names - .name_for(language.id.as_str()) - .context("missing native label for territory")?, - ); - result.push((code.clone(), default_ret, localized_ret)); + .context("territory not found")?; + + let names = &territory.names; + let territory_label = names + .name_for(&ui_language) + .or_else(|| names.name_for(DEFAULT_LANG)) + .unwrap_or(territory.id.to_string()); + + result.push((code.clone(), language_label, territory_label)) } Ok(result) diff --git a/rust/agama-lib/src/proxies.rs b/rust/agama-lib/src/proxies.rs index d4d049e597..97007935d2 100644 --- a/rust/agama-lib/src/proxies.rs +++ b/rust/agama-lib/src/proxies.rs @@ -81,7 +81,7 @@ trait Locale { fn commit(&self) -> zbus::Result<()>; /// ListLocales method - fn list_locales(&self) -> zbus::Result>; + fn list_locales(&self) -> zbus::Result>; /// ListTimezones method fn list_timezones(&self, locale: &str) -> zbus::Result>; diff --git a/web/src/client/language.js b/web/src/client/language.js index 8ada871498..179177d635 100644 --- a/web/src/client/language.js +++ b/web/src/client/language.js @@ -52,9 +52,10 @@ class LanguageClient { const proxy = await this.client.proxy(LANGUAGE_IFACE); const locales = await proxy.ListLocales(); return locales.map(locale => { - const [id, [language, territory], [,]] = locale; + const [id, language, territory] = locale; + // FIXME: do not format the language here const name = `${language} (${territory})`; - return { id, name }; + return { id, name, language, territory }; }); } From caf8451e5065b60cca591fc3d93afcac1efe7626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 21 Nov 2023 17:13:16 +0000 Subject: [PATCH 03/58] [web] Split l10n contexts - One context for installer localization and another for target system localization. --- web/src/App.jsx | 4 +- web/src/App.test.jsx | 9 +- web/src/client/index.js | 8 +- web/src/client/l10n.js | 247 ++++++++++++++++ .../client/{language.test.js => l10n.test.js} | 37 +-- web/src/client/language.js | 118 -------- web/src/components/l10n/LanguageSwitcher.jsx | 4 +- .../components/l10n/LanguageSwitcher.test.jsx | 6 +- web/src/components/overview/L10nSection.jsx | 74 ++--- .../components/overview/L10nSection.test.jsx | 35 +-- web/src/context/agama.jsx | 17 +- web/src/context/installerL10n.jsx | 261 +++++++++++++++++ .../{l10n.test.jsx => installerL10n.test.jsx} | 70 ++--- web/src/context/l10n.jsx | 263 +++--------------- web/src/test-utils.js | 9 +- 15 files changed, 666 insertions(+), 496 deletions(-) create mode 100644 web/src/client/l10n.js rename web/src/client/{language.test.js => l10n.test.js} (58%) delete mode 100644 web/src/client/language.js create mode 100644 web/src/context/installerL10n.jsx rename web/src/context/{l10n.test.jsx => installerL10n.test.jsx} (77%) diff --git a/web/src/App.jsx b/web/src/App.jsx index 4099afcd10..c374c3c87b 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -41,7 +41,7 @@ import { } from "~/components/core"; import { LanguageSwitcher } from "./components/l10n"; import { Layout, Loading, Title } from "./components/layout"; -import { useL10n } from "./context/l10n"; +import { useInstallerL10n } from "./context/installerL10n"; // D-Bus connection attempts before displaying an error. const ATTEMPTS = 3; @@ -57,7 +57,7 @@ function App() { const client = useInstallerClient(); const { attempt } = useInstallerClientStatus(); const { products } = useProduct(); - const { language } = useL10n(); + const { language } = useInstallerL10n(); const [status, setStatus] = useState(undefined); const [phase, setPhase] = useState(undefined); diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx index 59b0753521..fb7695a70e 100644 --- a/web/src/App.test.jsx +++ b/web/src/App.test.jsx @@ -70,9 +70,12 @@ describe("App", () => { onPhaseChange: onPhaseChangeFn, onStatusChange: onStatusChangeFn, }, - language: { - getUILanguage: jest.fn().mockResolvedValue("en-us"), - setUILanguage: jest.fn().mockResolvedValue("en-us"), + l10n: { + locales: jest.fn().mockResolvedValue([["en_us", "English", "United States"]]), + getLocales: jest.fn().mockResolvedValue(["en_us"]), + getUILocale: jest.fn().mockResolvedValue("en_us"), + setUILocale: jest.fn().mockResolvedValue("en_us"), + onLocalesChange: jest.fn() } }; }); diff --git a/web/src/client/index.js b/web/src/client/index.js index 34a656972e..34942691c8 100644 --- a/web/src/client/index.js +++ b/web/src/client/index.js @@ -21,7 +21,7 @@ // @ts-check -import { LanguageClient } from "./language"; +import { L10nClient } from "./l10n"; import { ManagerClient } from "./manager"; import { Monitor } from "./monitor"; import { SoftwareClient } from "./software"; @@ -37,7 +37,7 @@ const MANAGER_SERVICE = "org.opensuse.Agama.Manager1"; /** * @typedef {object} InstallerClient - * @property {LanguageClient} language - language client. + * @property {L10nClient} l10n - localization client. * @property {ManagerClient} manager - manager client. * @property {Monitor} monitor - service monitor. * @property {NetworkClient} network - network client. @@ -71,7 +71,7 @@ const MANAGER_SERVICE = "org.opensuse.Agama.Manager1"; * @return {InstallerClient} */ const createClient = (address = "unix:path=/run/agama/bus") => { - const language = new LanguageClient(address); + const l10n = new L10nClient(address); const manager = new ManagerClient(address); const monitor = new Monitor(address, MANAGER_SERVICE); const network = new NetworkClient(); @@ -122,7 +122,7 @@ const createClient = (address = "unix:path=/run/agama/bus") => { }; return { - language, + l10n, manager, monitor, network, diff --git a/web/src/client/l10n.js b/web/src/client/l10n.js new file mode 100644 index 0000000000..687ef68f0b --- /dev/null +++ b/web/src/client/l10n.js @@ -0,0 +1,247 @@ +/* + * Copyright (c) [2022-2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +// @ts-check +import DBusClient from "./dbus"; + +const LOCALE_SERVICE = "org.opensuse.Agama1"; +const LOCALE_IFACE = "org.opensuse.Agama1.Locale"; +const LOCALE_PATH = "/org/opensuse/Agama1/Locale"; + +/** + * @typedef {object} Timezone + * @property {string} id - Timezone id (e.g., "Atlantic/Canary"). + * @property {Array} parts - Name of the timezone parts (e.g., ["Atlantic", "Canary"]). + */ + +/** + * @typedef {object} Locale + * @property {string} id - Language id (e.g., "en_US"). + * @property {string} name - Language name (e.g., "English"). + * @property {string} territory - Territory name (e.g., "United States"). + */ + +/** + * @typedef {object} Keyboard + * @property {string} id - Keyboard id (e.g., "us"). + * @property {string} name - Keyboard name (e.g., "English"). + * @property {string} territory - Territory name (e.g., "United States"). + */ + +/** + * Manages localization. + */ +class L10nClient { + /** + * @param {string|undefined} address - D-Bus address; if it is undefined, it uses the system bus. + */ + constructor(address = undefined) { + this.client = new DBusClient(LOCALE_SERVICE, address); + } + + /** + * Available locales to translate the installer UI. + * + * Note that name and territory are localized to its own language: + * { id: "es", name: "Español", territory: "España" } + * + * @return {Promise>} + */ + async UILocales() { + const proxy = await this.client.proxy(LOCALE_IFACE); + const locales = await proxy.ListUILocales(); + + // TODO: D-Bus currently returns the id only + return locales.map(id => this.buildLocale([id, "", ""])); + } + + /** + * Selected locale to translate the installer UI. + * + * @return {Promise} Locale id. + */ + async getUILocale() { + const proxy = await this.client.proxy(LOCALE_IFACE); + return proxy.UILocale; + } + + /** + * Sets the locale to translate the installer UI. + * + * @param {String} id - Locale id. + * @return {Promise} + */ + async setUILocale(id) { + const proxy = await this.client.proxy(LOCALE_IFACE); + proxy.UILocale = id; + } + + /** + * All possible timezones for the target system. + * + * @return {Promise>} + */ + async timezones() { + const proxy = await this.client.proxy(LOCALE_IFACE); + const timezones = await proxy.ListTimezones(); + + // TODO: D-Bus currently returns the timezone parts only + return timezones.map(parts => this.buildTimezone(["", parts])); + } + + /** + * Timezone selected for the target system. + * + * @return {Promise} Id of the timezone. + */ + async getTimezone() { + const proxy = await this.client.proxy(LOCALE_IFACE); + return proxy.Timezone; + } + + /** + * Sets the timezone for the target system. + * + * @param {string} id - Id of the timezone. + * @return {Promise} + */ + async setTimezone(id) { + const proxy = await this.client.proxy(LOCALE_IFACE); + proxy.Timezone = id; + } + + /** + * Available locales to install in the target system. + * + * @return {Promise>} + */ + async locales() { + const proxy = await this.client.proxy(LOCALE_IFACE); + const locales = await proxy.ListLocales(); + + return locales.map(this.buildLocale); + } + + /** + * Locales selected to install in the target system. + * + * @return {Promise>} Ids of the locales. + */ + async getLocales() { + const proxy = await this.client.proxy(LOCALE_IFACE); + return proxy.Locales; + } + + /** + * Sets the locales to install in the target system. + * + * @param {Array} ids - Ids of the locales. + * @return {Promise} + */ + async setLocales(ids) { + const proxy = await this.client.proxy(LOCALE_IFACE); + proxy.Locales = ids; + } + + /** + * Available keyboards to install in the target system. + * + * Note that name and territory are localized to the current selected UI language: + * { id: "es", name: "Spanish", territory: "Spain" } + * + * @return {Promise>} + */ + async keyboards() { + const proxy = await this.client.proxy(LOCALE_IFACE); + const keyboards = await proxy.ListVConsoleKeyboards(); + + // TODO: D-Bus currently returns the id only + return keyboards.map(id => this.buildKeyboard([id, "", ""])); + } + + /** + * Keyboard selected to install in the target system. + * + * @return {Promise} Id of the keyboard. + */ + async getKeyboard() { + const proxy = await this.client.proxy(LOCALE_IFACE); + return proxy.VConsoleKeyboard; + } + + /** + * Sets the keyboard to install in the target system. + * + * @param {string} id - Id of the keyboard. + * @return {Promise} + */ + async setKeyboard(id) { + const proxy = await this.client.proxy(LOCALE_IFACE); + proxy.VConsoleKeyboard = id; + } + + /** + * Register a callback to run when properties in the Language object change + * + * @param {(language: string) => void} handler - function to call when the language change + * @return {import ("./dbus").RemoveFn} function to disable the callback + */ + onLocalesChange(handler) { + return this.client.onObjectChanged(LOCALE_PATH, LOCALE_IFACE, changes => { + if ("Locales" in changes) { + const selectedIds = changes.Locales.v; + handler(selectedIds); + } + }); + } + + /** + * @private + * + * @param {[string, Array]} dbusTimezone + * @returns {Timezone} + */ + buildTimezone([id, parts]) { + return ({ id, parts }); + } + + /** + * @private + * + * @param {[string, string, string]} dbusLocale + * @returns {Locale} + */ + buildLocale([id, name, territory]) { + return ({ id, name, territory }); + } + + /** + * @private + * + * @param {[string, string, string]} dbusKeyboard + * @returns {Keyboard} + */ + buildKeyboard([id, name, territory]) { + return ({ id, name, territory }); + } +} + +export { L10nClient }; diff --git a/web/src/client/language.test.js b/web/src/client/l10n.test.js similarity index 58% rename from web/src/client/language.test.js rename to web/src/client/l10n.test.js index 6a33eb638a..590d177143 100644 --- a/web/src/client/language.test.js +++ b/web/src/client/l10n.test.js @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -23,37 +23,40 @@ // cspell:ignore Cestina import DBusClient from "./dbus"; -import { LanguageClient } from "./language"; +import { L10nClient } from "./l10n"; jest.mock("./dbus"); -const langProxy = { - wait: jest.fn(), - SupportedLocales: ["es_ES.UTF-8", "en_US.UTF-8"], +const L10N_IFACE = "org.opensuse.Agama1.Locale"; + +const l10nProxy = { ListLocales: jest.fn().mockResolvedValue( [ - ["es_ES.UTF-8", ["Spanish", "Spain"], ["Español", "España"]], - ["en_US.UTF-8", ['English', 'United States'], ['English', 'United States']] + ["es_ES.UTF-8", "Spanish", "Spain"], + ["en_US.UTF-8", "English", "United States"] ] ), }; -jest.mock("./dbus"); - beforeEach(() => { // @ts-ignore DBusClient.mockImplementation(() => { - return { proxy: () => langProxy }; + return { + proxy: (iface) => { + if (iface === L10N_IFACE) return l10nProxy; + } + }; }); }); -describe("#getLanguages", () => { - it("returns the list of available languages", async () => { - const client = new LanguageClient(); - const availableLanguages = await client.getLanguages(); - expect(availableLanguages).toEqual([ - { id: "es_ES.UTF-8", name: "Spanish" }, - { id: "en_US.UTF-8", name: "English" } +describe("#locales", () => { + it("returns the list of available locales", async () => { + const client = new L10nClient(); + const locales = await client.locales(); + + expect(locales).toEqual([ + { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, + { id: "en_US.UTF-8", name: "English", territory: "United States" } ]); }); }); diff --git a/web/src/client/language.js b/web/src/client/language.js deleted file mode 100644 index 179177d635..0000000000 --- a/web/src/client/language.js +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright (c) [2022] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @ts-check -import DBusClient from "./dbus"; - -const LANGUAGE_SERVICE = "org.opensuse.Agama1"; -const LANGUAGE_IFACE = "org.opensuse.Agama1.Locale"; -const LANGUAGE_PATH = "/org/opensuse/Agama1/Locale"; - -/** - * @typedef {object} Language - * @property {string} id - Language ID (e.g., "en_US") - * @property {string} name - Language name (e.g., "English (US)") - */ - -/** - * Allows getting the list of available languages and selecting one for installation. - */ -class LanguageClient { - /** - * @param {string|undefined} address - D-Bus address; if it is undefined, it uses the system bus. - */ - constructor(address = undefined) { - this.client = new DBusClient(LANGUAGE_SERVICE, address); - } - - /** - * Returns the list of available languages - * - * @return {Promise>} - */ - async getLanguages() { - const proxy = await this.client.proxy(LANGUAGE_IFACE); - const locales = await proxy.ListLocales(); - return locales.map(locale => { - const [id, language, territory] = locale; - // FIXME: do not format the language here - const name = `${language} (${territory})`; - return { id, name, language, territory }; - }); - } - - /** - * Returns the languages selected for installation - * - * @return {Promise>} IDs of the selected languages - */ - async getSelectedLanguages() { - const proxy = await this.client.proxy(LANGUAGE_IFACE); - return proxy.Locales; - } - - /** - * Set the languages to install - * - * @param {string} langIDs - Identifier of languages to install - * @return {Promise} - */ - async setLanguages(langIDs) { - const proxy = await this.client.proxy(LANGUAGE_IFACE); - proxy.Locales = langIDs; - } - - /** - * Returns the current backend locale - * - * @return {Promise} the locale string - */ - async getUILanguage() { - const proxy = await this.client.proxy(LANGUAGE_IFACE); - return proxy.UILocale; - } - - /** - * Set the backend language - * - * @param {String} lang the locale string - * @return {Promise} - */ - async setUILanguage(lang) { - const proxy = await this.client.proxy(LANGUAGE_IFACE); - proxy.UILocale = lang; - } - - /** - * Register a callback to run when properties in the Language object change - * - * @param {(language: string) => void} handler - function to call when the language change - * @return {import ("./dbus").RemoveFn} function to disable the callback - */ - onLanguageChange(handler) { - return this.client.onObjectChanged(LANGUAGE_PATH, LANGUAGE_IFACE, changes => { - const selected = changes.Locales.v[0]; - handler(selected); - }); - } -} - -export { LanguageClient }; diff --git a/web/src/components/l10n/LanguageSwitcher.jsx b/web/src/components/l10n/LanguageSwitcher.jsx index e2fddf2d2b..76d1562d01 100644 --- a/web/src/components/l10n/LanguageSwitcher.jsx +++ b/web/src/components/l10n/LanguageSwitcher.jsx @@ -23,11 +23,11 @@ import React, { useCallback, useState } from "react"; import { Icon } from "../layout"; import { FormSelect, FormSelectOption } from "@patternfly/react-core"; import { _ } from "~/i18n"; -import { useL10n } from "~/context/l10n"; +import { useInstallerL10n } from "~/context/installerL10n"; import cockpit from "~/lib/cockpit"; export default function LanguageSwitcher() { - const { language, changeLanguage } = useL10n(); + const { language, changeLanguage } = useInstallerL10n(); const [selected, setSelected] = useState(null); const languages = cockpit.manifests.agama?.locales || []; diff --git a/web/src/components/l10n/LanguageSwitcher.test.jsx b/web/src/components/l10n/LanguageSwitcher.test.jsx index e844dbb9dc..f3ba15fcdb 100644 --- a/web/src/components/l10n/LanguageSwitcher.test.jsx +++ b/web/src/components/l10n/LanguageSwitcher.test.jsx @@ -40,9 +40,9 @@ jest.mock("~/lib/cockpit", () => ({ } })); -jest.mock("~/context/l10n", () => ({ - ...jest.requireActual("~/context/l10n"), - useL10n: () => ({ +jest.mock("~/context/installerL10n", () => ({ + ...jest.requireActual("~/context/installerL10n"), + useInstallerL10n: () => ({ language: mockLanguage, changeLanguage: mockChangeLanguageFn }) diff --git a/web/src/components/overview/L10nSection.jsx b/web/src/components/overview/L10nSection.jsx index 1429b7f1a8..96b47372f2 100644 --- a/web/src/components/overview/L10nSection.jsx +++ b/web/src/components/overview/L10nSection.jsx @@ -19,73 +19,45 @@ * find current contact information at www.suse.com. */ -import React, { useEffect, useState } from "react"; +import React from "react"; import { Text } from "@patternfly/react-core"; -import { Em, Section, SectionSkeleton } from "~/components/core"; -import { useCancellablePromise } from "~/utils"; -import { useInstallerClient } from "~/context/installer"; +import { Em, If, Section, SectionSkeleton } from "~/components/core"; +import { useL10n } from "~/context/l10n"; import { _ } from "~/i18n"; -const initialState = { - busy: true, - language: undefined, - errors: [] -}; - -export default function L10nSection({ showErrors }) { - const [state, setState] = useState(initialState); - const { language: client } = useInstallerClient(); - const { cancellablePromise } = useCancellablePromise(); - - const updateState = ({ ...payload }) => { - setState(previousState => ({ ...previousState, ...payload })); - }; - - useEffect(() => { - const loadLanguages = async () => { - const languages = await cancellablePromise(client.getLanguages()); - const [language] = await cancellablePromise(client.getSelectedLanguages()); - updateState({ languages, language, busy: false }); - }; - - // TODO: use these errors? - loadLanguages().catch(console.error); +const Content = ({ locales }) => { + // Only considering the first locale. + const locale = locales[0]; - return client.onLanguageChange(language => { - updateState({ language }); - }); - }, [client, cancellablePromise]); + // TRANSLATORS: %s will be replaced by a language name and code, example: "English (en_US.UTF-8)". + const [msg1, msg2] = _("The system will use %s as its default language.").split("%s"); - const errors = showErrors ? state.errors : []; - - const SectionContent = () => { - const { busy, languages, language } = state; - - if (busy) return ; + return ( + + {msg1}{`${locale.name} (${locale.id})`}{msg2} + + ); +}; - const selected = languages.find(lang => lang.id === language); +export default function L10nSection() { + const { selectedLocales } = useL10n(); - // TRANSLATORS: %s will be replaced by a language name and code, - // example: "English (en_US.UTF-8)" - const [msg1, msg2] = _("The system will use %s as its default language.").split("%s"); - return ( - - {msg1}{`${selected.name} (${selected.id})`}{msg2} - - ); - }; + const isLoading = selectedLocales.length === 0; return (
- + } + else={} + />
); } diff --git a/web/src/components/overview/L10nSection.test.jsx b/web/src/components/overview/L10nSection.test.jsx index ec282d6fc2..78e6c57097 100644 --- a/web/src/components/overview/L10nSection.test.jsx +++ b/web/src/components/overview/L10nSection.test.jsx @@ -27,48 +27,43 @@ import { createClient } from "~/client"; jest.mock("~/client"); -const languages = [ - { id: "en_US", name: "English" }, - { id: "de_DE", name: "German" } +const locales = [ + { id: "en_US", name: "English", territory: "United States" }, + { id: "de_DE", name: "German", territory: "Germany" } ]; -let onLanguageChangeFn = jest.fn(); - -const languageMock = { - getLanguages: () => Promise.resolve(languages), - getSelectedLanguages: () => Promise.resolve(["en_US"]), +const l10nClientMock = { + locales: jest.fn().mockResolvedValue(locales), + getLocales: jest.fn().mockResolvedValue(["en_US"]), + getUILocale: jest.fn().mockResolvedValue("en_US"), + onLocalesChange: jest.fn() }; beforeEach(() => { // if defined outside, the mock is cleared automatically createClient.mockImplementation(() => { return { - language: { - ...languageMock, - onLanguageChange: onLanguageChangeFn - } + l10n: l10nClientMock }; }); }); -it("displays the selected language", async () => { - installerRender(); +it("displays the selected locale", async () => { + installerRender(, { withL10n: true }); await screen.findByText("English (en_US)"); }); -describe("when the Language Selection changes", () => { +describe("when the selected locales change", () => { it("updates the proposal", async () => { const [mockFunction, callbacks] = createCallbackMock(); - onLanguageChangeFn = mockFunction; + l10nClientMock.onLocalesChange = mockFunction; - installerRender(); + installerRender(, { withL10n: true }); await screen.findByText("English (en_US)"); const [cb] = callbacks; - act(() => { - cb("de_DE"); - }); + act(() => cb(["de_DE"])); await screen.findByText("German (de_DE)"); }); diff --git a/web/src/context/agama.jsx b/web/src/context/agama.jsx index 0aa16e811b..5ee0b7d22e 100644 --- a/web/src/context/agama.jsx +++ b/web/src/context/agama.jsx @@ -23,6 +23,7 @@ import React from "react"; import { InstallerClientProvider } from "./installer"; +import { InstallerL10nProvider } from "./installerL10n"; import { L10nProvider } from "./l10n"; import { ProductProvider } from "./product"; import { NotificationProvider } from "./notification"; @@ -36,13 +37,15 @@ import { NotificationProvider } from "./notification"; function AgamaProviders({ children }) { return ( - - - - {children} - - - + + + + + {children} + + + + ); } diff --git a/web/src/context/installerL10n.jsx b/web/src/context/installerL10n.jsx new file mode 100644 index 0000000000..df6826ac4c --- /dev/null +++ b/web/src/context/installerL10n.jsx @@ -0,0 +1,261 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +// @ts-check + +import React, { useCallback, useEffect, useState } from "react"; +import { useCancellablePromise, locationReload, setLocationSearch } from "~/utils"; +import cockpit from "../lib/cockpit"; +import { useInstallerClient } from "./installer"; + +const L10nContext = React.createContext(null); + +/** + * @typedef {object} L10nContext + * @property {string|undefined} language - Current language. + * @property {(language: string) => void} changeLanguage - Function to change the current language. + * + * @return {L10nContext} + */ +function useInstallerL10n() { + const context = React.useContext(L10nContext); + + if (!context) { + throw new Error("useInstallerL10n must be used within a InstallerL10nContext"); + } + + return context; +} + +/** + * Current language according to Cockpit (in xx_XX format). + * + * It takes the language from the CockpitLang cookie. + * + * @return {string|undefined} Undefined if language is not set. + */ +function cockpitLanguage() { + // language from cookie, empty string if not set (regexp taken from Cockpit) + // https://github.com/cockpit-project/cockpit/blob/98a2e093c42ea8cd2431cf15c7ca0e44bb4ce3f1/pkg/shell/shell-modals.jsx#L91 + const languageString = decodeURIComponent(document.cookie.replace(/(?:(?:^|.*;\s*)CockpitLang\s*=\s*([^;]*).*$)|^.*$/, "$1")); + if (languageString) { + return languageString.toLowerCase(); + } +} + +/** + * Helper function for storing the Cockpit language. + * + * Automatically converts the language from xx_XX to xx-xx, as it is the one used by Cockpit. + * + * @param {string} language - The new locale (e.g., "cs", "cs_CZ"). + * @return {boolean} True if the locale was changed. + */ +function storeCockpitLanguage(language) { + const current = cockpitLanguage(); + if (current === language) return false; + + // Code taken from Cockpit. + const cookie = "CockpitLang=" + encodeURIComponent(language) + "; path=/; expires=Sun, 16 Jul 3567 06:23:41 GMT"; + document.cookie = cookie; + window.localStorage.setItem("cockpit.lang", language); + return true; +} + +/** + * Returns the language tag from the query string. + * + * Query supports 'xx-xx', 'xx_xx', 'xx-XX' and 'xx_XX' formats. + * + * @return {string|undefined} Undefined if not set. + */ +function languageFromQuery() { + const lang = (new URLSearchParams(window.location.search)).get("lang"); + if (!lang) return undefined; + + const [language, country] = lang.toLowerCase().split(/[-_]/); + return (country) ? `${language}-${country}` : language; +} + +/** + * Generates a RFC 5646 (or BCP 78) language tag from a locale. + * + * @param {string} locale + * @return {string} + * + * @private + * @see https://datatracker.ietf.org/doc/html/rfc5646 + * @see https://www.rfc-editor.org/info/bcp78 + */ +function languageFromLocale(locale) { + return locale.replace("_", "-").toLowerCase(); +} + +/** + * Converts a RFC 5646 language tag to a locale. + * + * @param {string} language + * @return {string} + * + * @private + * @see https://datatracker.ietf.org/doc/html/rfc5646 + * @see https://www.rfc-editor.org/info/bcp78 + */ +function languageToLocale(language) { + const [lang, country] = language.split("-"); + return (country) ? `${lang}_${country.toUpperCase()}` : lang; +} + +/** + * List of RFC 5646 (or BCP 78) language tags from the navigator. + * + * @return {Array} + */ +function navigatorLanguages() { + return navigator.languages.map(l => l.toLowerCase()); +} + +/** + * Returns the first supported language from the given list. + * + * @param {Array} languages + * @return {string|undefined} Undefined if none of the given languages is supported. + */ +function findSupportedLanguage(languages) { + const supported = Object.keys(cockpit.manifests.agama?.locales || {}); + + for (const candidate of languages) { + const [language, country] = candidate.split("-"); + + const match = supported.find(s => { + const [supportedLanguage, supportedCountry] = s.split("-"); + if (language === supportedLanguage) { + return country === undefined || country === supportedCountry; + } else { + return false; + } + }); + if (match) return match; + } +} + +/** + * Reloads the page. + * + * It uses the window.location.replace instead of the reload function synchronizing the "lang" + * argument from the URL if present. + * + * @param {string} newLanguage + */ +function reload(newLanguage) { + const query = new URLSearchParams(window.location.search); + if (query.has("lang") && query.get("lang") !== newLanguage) { + query.set("lang", newLanguage); + // Setting location search with a different value makes the browser to navigate to the new URL. + setLocationSearch(query.toString()); + } else { + locationReload(); + } +} + +/** + * This provider sets the installer locale. By default, it uses the URL "lang" query parameter or + * the preferred locale from the browser and synchronizes the UI and the backend locales. To + * activate a new locale it reloads the whole page. + * + * Additionally, it offers a function to change the current locale. + * + * The format of the language tag in the query parameter follows the + * [RFC 5646](https://datatracker.ietf.org/doc/html/rfc5646) specification. + * + * @param {object} props + * @param {React.ReactNode} [props.children] - Content to display within the wrapper. + * @param {import("~/client").InstallerClient} [props.client] - Client. + * + * @see useInstallerL10n + */ +function InstallerL10nProvider({ children }) { + const client = useInstallerClient(); + const [language, setLanguage] = useState(undefined); + const [backendPending, setBackendPending] = useState(false); + const { cancellablePromise } = useCancellablePromise(); + + const storeInstallerLanguage = useCallback(async (newLanguage) => { + if (!client) { + setBackendPending(true); + return false; + } + + const locale = await cancellablePromise(client.l10n.getUILocale()); + const currentLanguage = languageFromLocale(locale); + + if (currentLanguage !== newLanguage) { + // FIXME: fallback to en-US if the language is not supported. + await cancellablePromise(client.l10n.setUILocale(languageToLocale(newLanguage))); + return true; + } + + return false; + }, [client, cancellablePromise]); + + const changeLanguage = useCallback(async (lang) => { + const wanted = lang || languageFromQuery(); + + if (wanted === "xx" || wanted === "xx-xx") { + cockpit.language = wanted; + setLanguage(wanted); + return; + } + + const current = cockpitLanguage(); + const candidateLanguages = [wanted, current].concat(navigatorLanguages()).filter(l => l); + const newLanguage = findSupportedLanguage(candidateLanguages) || "en-us"; + + let mustReload = storeCockpitLanguage(newLanguage); + mustReload = await storeInstallerLanguage(newLanguage) || mustReload; + + if (mustReload) { + reload(newLanguage); + } else { + setLanguage(newLanguage); + } + }, [storeInstallerLanguage, setLanguage]); + + useEffect(() => { + if (!language) changeLanguage(); + }, [changeLanguage, language]); + + useEffect(() => { + if (!client || !backendPending) return; + + storeInstallerLanguage(language); + setBackendPending(false); + }, [client, language, backendPending, storeInstallerLanguage]); + + return ( + {children} + ); +} + +export { + InstallerL10nProvider, + useInstallerL10n +}; diff --git a/web/src/context/l10n.test.jsx b/web/src/context/installerL10n.test.jsx similarity index 77% rename from web/src/context/l10n.test.jsx rename to web/src/context/installerL10n.test.jsx index dd932b6102..fa3cf79d7b 100644 --- a/web/src/context/l10n.test.jsx +++ b/web/src/context/installerL10n.test.jsx @@ -25,17 +25,17 @@ import React from "react"; import { render, waitFor, screen } from "@testing-library/react"; -import { L10nProvider } from "~/context/l10n"; +import { InstallerL10nProvider } from "~/context/installerL10n"; import { InstallerClientProvider } from "./installer"; import * as utils from "~/utils"; -const getUILanguageFn = jest.fn().mockResolvedValue(); -const setUILanguageFn = jest.fn().mockResolvedValue(); +const getUILocaleFn = jest.fn().mockResolvedValue(); +const setUILocaleFn = jest.fn().mockResolvedValue(); const client = { - language: { - getUILanguage: getUILanguageFn, - setUILanguage: setUILanguageFn + l10n: { + getUILocale: getUILocaleFn, + setUILocale: setUILocaleFn }, onDisconnect: jest.fn() }; @@ -72,7 +72,7 @@ const TranslatedContent = () => { return <>{text[lang]}; }; -describe("L10nProvider", () => { +describe("InstallerL10nProvider", () => { beforeAll(() => { jest.spyOn(utils, "locationReload").mockImplementation(utils.noop); jest.spyOn(utils, "setLocationSearch"); @@ -95,13 +95,13 @@ describe("L10nProvider", () => { describe("when the Cockpit language is already set", () => { beforeEach(() => { document.cookie = "CockpitLang=en-us; path=/;"; - getUILanguageFn.mockResolvedValueOnce("en_US"); + getUILocaleFn.mockResolvedValueOnce("en_US"); }); it("displays the children content and does not reload", async () => { render( - + ); @@ -115,14 +115,14 @@ describe("L10nProvider", () => { describe("when the Cockpit language is set to an unsupported language", () => { beforeEach(() => { document.cookie = "CockpitLang=de-de; path=/;"; - getUILanguageFn.mockResolvedValueOnce("de_DE"); - getUILanguageFn.mockResolvedValueOnce("es_ES"); + getUILocaleFn.mockResolvedValueOnce("de_DE"); + getUILocaleFn.mockResolvedValueOnce("es_ES"); }); it("uses the first supported language from the browser", async () => { render( - + ); @@ -131,27 +131,27 @@ describe("L10nProvider", () => { // renders again after reloading render( - + ); await waitFor(() => screen.getByText("hola")); - expect(setUILanguageFn).toHaveBeenCalledWith("es_ES"); + expect(setUILocaleFn).toHaveBeenCalledWith("es_ES"); }); }); describe("when the Cockpit language is not set", () => { beforeEach(() => { // Ensure both, UI and backend mock languages, are in sync since - // client.setUILanguage is mocked too. + // client.setUILocale is mocked too. // See navigator.language in the beforeAll at the top of the file. - getUILanguageFn.mockResolvedValue("es_ES"); + getUILocaleFn.mockResolvedValue("es_ES"); }); it("sets the preferred language from browser and reloads", async () => { render( - + ); @@ -160,7 +160,7 @@ describe("L10nProvider", () => { // renders again after reloading render( - + ); await waitFor(() => screen.getByText("hola")); @@ -174,7 +174,7 @@ describe("L10nProvider", () => { it("sets the first which language matches", async () => { render( - + ); @@ -183,7 +183,7 @@ describe("L10nProvider", () => { // renders again after reloading render( - + ); await waitFor(() => screen.getByText("hola!")); @@ -200,19 +200,19 @@ describe("L10nProvider", () => { describe("when the Cockpit language is already set to 'cs-cz'", () => { beforeEach(() => { document.cookie = "CockpitLang=cs-cz; path=/;"; - getUILanguageFn.mockResolvedValueOnce("cs_CZ"); + getUILocaleFn.mockResolvedValueOnce("cs_CZ"); }); it("displays the children content and does not reload", async () => { render( - + ); // children are displayed await screen.findByText("ahoj"); - expect(setUILanguageFn).not.toHaveBeenCalled(); + expect(setUILocaleFn).not.toHaveBeenCalled(); expect(document.cookie).toEqual("CockpitLang=cs-cz"); expect(utils.locationReload).not.toHaveBeenCalled(); @@ -223,15 +223,15 @@ describe("L10nProvider", () => { describe("when the Cockpit language is set to 'en-us'", () => { beforeEach(() => { document.cookie = "CockpitLang=en-us; path=/;"; - getUILanguageFn.mockResolvedValueOnce("en_US"); - getUILanguageFn.mockResolvedValueOnce("cs_CZ"); - setUILanguageFn.mockResolvedValue(); + getUILocaleFn.mockResolvedValueOnce("en_US"); + getUILocaleFn.mockResolvedValueOnce("cs_CZ"); + setUILocaleFn.mockResolvedValue(); }); it("sets the 'cs-cz' language and reloads", async () => { render( - + ); @@ -240,26 +240,26 @@ describe("L10nProvider", () => { // renders again after reloading render( - + ); await waitFor(() => screen.getByText("ahoj")); - expect(setUILanguageFn).toHaveBeenCalledWith("cs_CZ"); + expect(setUILocaleFn).toHaveBeenCalledWith("cs_CZ"); }); }); describe("when the Cockpit language is not set", () => { beforeEach(() => { - getUILanguageFn.mockResolvedValueOnce("en_US"); - getUILanguageFn.mockResolvedValueOnce("cs_CZ"); - setUILanguageFn.mockResolvedValue(); + getUILocaleFn.mockResolvedValueOnce("en_US"); + getUILocaleFn.mockResolvedValueOnce("cs_CZ"); + setUILocaleFn.mockResolvedValue(); }); it("sets the 'cs-cz' language and reloads", async () => { render( - + ); @@ -268,12 +268,12 @@ describe("L10nProvider", () => { // reload the component render( - + ); await waitFor(() => screen.getByText("ahoj")); - expect(setUILanguageFn).toHaveBeenCalledWith("cs_CZ"); + expect(setUILocaleFn).toHaveBeenCalledWith("cs_CZ"); }); }); }); diff --git a/web/src/context/l10n.jsx b/web/src/context/l10n.jsx index d3a725b2fc..798714d16e 100644 --- a/web/src/context/l10n.jsx +++ b/web/src/context/l10n.jsx @@ -19,251 +19,52 @@ * find current contact information at www.suse.com. */ -// @ts-check - -import React, { useCallback, useEffect, useState } from "react"; -import { useCancellablePromise, locationReload, setLocationSearch } from "~/utils"; -import cockpit from "../lib/cockpit"; +import React, { useContext, useEffect, useState } from "react"; +import { useCancellablePromise } from "~/utils"; import { useInstallerClient } from "./installer"; -const L10nContext = React.createContext(null); - -/** - * @typedef {object} L10nContext - * @property {string|undefined} language - current language - * @property {(lang: string) => void} changeLanguage - function to change the current language - * - * @return {L10nContext} L10n context - */ -function useL10n() { - const context = React.useContext(L10nContext); - - if (!context) { - throw new Error("useL10n must be used within a L10nContext"); - } - - return context; -} - -/** - * Returns the current locale according to Cockpit - * - * It takes the locale from the CockpitLang cookie. - * - * @return {string|undefined} language tag in xx_XX format or undefined if - * it was not set. - */ -function cockpitLanguage() { - // language from cookie, empty string if not set (regexp taken from Cockpit) - // https://github.com/cockpit-project/cockpit/blob/98a2e093c42ea8cd2431cf15c7ca0e44bb4ce3f1/pkg/shell/shell-modals.jsx#L91 - const languageString = decodeURIComponent(document.cookie.replace(/(?:(?:^|.*;\s*)CockpitLang\s*=\s*([^;]*).*$)|^.*$/, "$1")); - if (languageString) { - return languageString.toLowerCase(); - } -} - -/** - * Helper function for storing the Cockpit language. - * - * This function automatically converts the language tag from xx_XX to xx-xx, - * as it is the one used by Cockpit. - * - * @param {string} lang the new language tag (like "cs", "cs_CZ",...) - * @return {boolean} returns true if the locale changed; false otherwise - */ -function storeUILanguage(lang) { - const current = cockpitLanguage(); - if (current === lang) { - return false; - } - // code taken from Cockpit - const cookie = "CockpitLang=" + encodeURIComponent(lang) + "; path=/; expires=Sun, 16 Jul 3567 06:23:41 GMT"; - document.cookie = cookie; - window.localStorage.setItem("cockpit.lang", lang); - return true; -} - -/** - * Returns the language from the query string. - * - * @return {string|undefined} language tag in 'xx-xx' format (or just 'xx') or undefined if it was - * not set. It supports 'xx-xx', 'xx_xx', 'xx-XX' and 'xx_XX'. - */ -function languageFromQuery() { - const lang = (new URLSearchParams(window.location.search)).get("lang"); - if (!lang) return undefined; - - const [language, country] = lang.toLowerCase().split(/[-_]/); - return (country) ? `${language}-${country}` : language; -} - -/** - * Converts a language tag from the backend to a one compatible with RFC 5646 or - * BCP 78 - * - * @param {string} tag - language tag from the backend - * @return {string} Language tag compatible with RFC 5646 or BCP 78 - * - * @private - * @see https://datatracker.ietf.org/doc/html/rfc5646 - * @see https://www.rfc-editor.org/info/bcp78 - */ -function languageFromBackend(tag) { - return tag.replace("_", "-").toLowerCase(); -} - -/** - * Converts a language tag compatible with RFC 5646 to the format used by the backend - * - * @param {string} tag - language tag from the backend - * @return {string} Language tag compatible with the backend - * - * @private - * @see https://datatracker.ietf.org/doc/html/rfc5646 - * @see https://www.rfc-editor.org/info/bcp78 - */ -function languageToBackend(tag) { - const [language, country] = tag.split("-"); - return (country) ? `${language}_${country.toUpperCase()}` : language; -} +const L10nContext = React.createContext({}); -/** - * Returns the list of languages from the navigator in RFC 5646 (or BCP 78) - * format - * - * @return {Array} List of languages from the navigator - */ -function navigatorLanguages() { - return navigator.languages.map(l => l.toLowerCase()); -} - -/** - * Returns the first supported language from the given list. - * - * @param {Array} languages - Candidate languages - * @return {string|undefined} First supported language or undefined if none - * of the given languages is supported. - */ -function findSupportedLanguage(languages) { - const supported = Object.keys(cockpit.manifests.agama?.locales || {}); - - for (const candidate of languages) { - const [language, country] = candidate.split("-"); - - const match = supported.find(s => { - const [supportedLanguage, supportedCountry] = s.split("-"); - if (language === supportedLanguage) { - return country === undefined || country === supportedCountry; - } else { - return false; - } - }); - if (match) return match; - } -} - -/** - * Reloads the page - * - * It uses the window.location.replace instead of the reload function - * synchronizing the "lang" argument from the URL if present. - * - * @param {string} newLanguage - */ -function reload(newLanguage) { - const query = new URLSearchParams(window.location.search); - if (query.has("lang") && query.get("lang") !== newLanguage) { - query.set("lang", newLanguage); - // Setting location search with a different value makes the browser to navigate - // to the new URL. - setLocationSearch(query.toString()); - } else { - locationReload(); - } -} - -/** - * This provider sets the application language. By default, it uses the - * URL "lang" query parameter or the preferred language from the browser and - * synchronizes the UI and the backend languages. To activate a new language it - * reloads the whole page. - * - * Additionally, it offers a function to change the current language. - * - * The format of the language tag follows the - * [RFC 5646](https://datatracker.ietf.org/doc/html/rfc5646) specification. - * - * @param {object} props - * @param {React.ReactNode} [props.children] - content to display within the wrapper - * @param {import("~/client").InstallerClient} [props.client] - client - * - * @see useL10n - */ function L10nProvider({ children }) { const client = useInstallerClient(); - const [language, setLanguage] = useState(undefined); - const [backendPending, setBackendPending] = useState(false); const { cancellablePromise } = useCancellablePromise(); + const [locales, setLocales] = useState(); + const [selectedLocales, setSelectedLocales] = useState(); - const storeBackendLanguage = useCallback(async languageString => { - if (!client) { - setBackendPending(true); - return false; - } - - const currentLang = await cancellablePromise(client.language.getUILanguage()); - const normalizedLang = languageFromBackend(currentLang); - - if (normalizedLang !== languageString) { - // FIXME: fallback to en-US if the language is not supported. - await cancellablePromise( - client.language.setUILanguage(languageToBackend(languageString)) - ); - return true; + useEffect(() => { + const load = async () => { + const locales = await cancellablePromise(client.l10n.locales()); + const selectedLocales = await cancellablePromise(client.l10n.getLocales()); + setLocales(locales); + setSelectedLocales(selectedLocales); + }; + + if (client) { + load().catch(console.error); } - return false; - }, [client, cancellablePromise]); - - const changeLanguage = useCallback(async lang => { - const wanted = lang || languageFromQuery(); + }, [client, setLocales, setSelectedLocales, cancellablePromise]); - if (wanted === "xx" || wanted === "xx-xx") { - cockpit.language = wanted; - setLanguage(wanted); - return; - } + useEffect(() => { + if (!client) return; - const current = cockpitLanguage(); - const candidateLanguages = [wanted, current].concat(navigatorLanguages()) - .filter(l => l); - const newLanguage = findSupportedLanguage(candidateLanguages) || "en-us"; + return client.l10n.onLocalesChange(setSelectedLocales); + }, [client, setSelectedLocales]); - let mustReload = storeUILanguage(newLanguage); - mustReload = await storeBackendLanguage(newLanguage) || mustReload; - if (mustReload) { - reload(newLanguage); - } else { - setLanguage(newLanguage); - } - }, [storeBackendLanguage, setLanguage]); + const value = { locales, selectedLocales }; + return {children}; +} - useEffect(() => { - if (!language) changeLanguage(); - }, [changeLanguage, language]); +function useL10n() { + const context = useContext(L10nContext); - useEffect(() => { - if (!client || !backendPending) return; + if (!context) { + throw new Error("useL10n must be used within a L10nProvider"); + } - storeBackendLanguage(language); - setBackendPending(false); - }, [client, language, backendPending, storeBackendLanguage]); + const { locales = [], selectedLocales: selectedIds = [] } = context; + const selectedLocales = selectedIds.map(id => locales.find(l => l.id === id)); - return ( - {children} - ); + return { locales, selectedLocales }; } -export { - L10nProvider, - useL10n -}; +export { L10nProvider, useL10n }; diff --git a/web/src/test-utils.js b/web/src/test-utils.js index 1848f5a0e0..8e7a5f143d 100644 --- a/web/src/test-utils.js +++ b/web/src/test-utils.js @@ -36,6 +36,7 @@ import { NotificationProvider } from "~/context/notification"; import { Layout } from "~/components/layout"; import { noop } from "./utils"; import cockpit from "./lib/cockpit"; +import { InstallerL10nProvider } from "./context/installerL10n"; import { L10nProvider } from "./context/l10n"; /** @@ -84,9 +85,11 @@ const Providers = ({ children, withL10n }) => { if (withL10n) { return ( - - {children} - + + + {children} + + ); } From 1a1d443ea1866d9dadd29dfdf953e01a17d81f02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 22 Nov 2023 07:12:13 +0000 Subject: [PATCH 04/58] Add a mechanism to read the xkeyboard-config DB --- rust/agama-locale-data/src/keyboard.rs | 3 + .../src/keyboard/xkb_config_registry.rs | 70 +++++++++++++++++++ rust/agama-locale-data/src/lib.rs | 1 + 3 files changed, 74 insertions(+) create mode 100644 rust/agama-locale-data/src/keyboard.rs create mode 100644 rust/agama-locale-data/src/keyboard/xkb_config_registry.rs diff --git a/rust/agama-locale-data/src/keyboard.rs b/rust/agama-locale-data/src/keyboard.rs new file mode 100644 index 0000000000..ce593d812f --- /dev/null +++ b/rust/agama-locale-data/src/keyboard.rs @@ -0,0 +1,3 @@ +pub mod xkb_config_registry; + +pub use xkb_config_registry::XkbConfigRegistry; diff --git a/rust/agama-locale-data/src/keyboard/xkb_config_registry.rs b/rust/agama-locale-data/src/keyboard/xkb_config_registry.rs new file mode 100644 index 0000000000..a1bdd07cb6 --- /dev/null +++ b/rust/agama-locale-data/src/keyboard/xkb_config_registry.rs @@ -0,0 +1,70 @@ +//! This module aims to read the information in the X Keyboard Configuration Database. +//! +//! https://freedesktop.org/Software/XKeyboardConfig + +use quick_xml::de::from_str; +use serde::Deserialize; +use std::{error::Error, fs}; + +const DB_PATH: &'static str = "/usr/share/X11/xkb/rules/base.xml"; + +/// X Keyboard Configuration Database +#[derive(Deserialize, Debug)] +pub struct XkbConfigRegistry { + #[serde(rename = "layoutList")] + pub layout_list: LayoutList, +} + +impl XkbConfigRegistry { + /// Reads the database from the given file + /// + /// - `path`: database path. + pub fn from(path: &str) -> Result> { + let contents = fs::read_to_string(&path)?; + Ok(from_str(&contents)?) + } + + /// Reads the database from the default path. + pub fn from_system() -> Result> { + Self::from(DB_PATH) + } +} + +#[derive(Deserialize, Debug)] +pub struct LayoutList { + #[serde(rename = "layout")] + pub layouts: Vec, +} + +#[derive(Deserialize, Debug)] +pub struct Layout { + #[serde(rename = "configItem")] + pub config_item: ConfigItem, + #[serde(rename = "variantList", default)] + pub variants_list: VariantList, +} + +#[derive(Deserialize, Debug)] +pub struct ConfigItem { + pub name: String, + #[serde(rename = "description")] + pub description: String, +} + +#[derive(Deserialize, Debug, Default)] +pub struct VariantList { + #[serde(rename = "variant", default)] + pub variants: Vec, +} + +#[derive(Deserialize, Debug)] +pub struct Variant { + #[serde(rename = "configItem")] + pub config_item: VariantConfigItem, +} + +#[derive(Deserialize, Debug)] +pub struct VariantConfigItem { + pub name: String, + pub description: String, +} diff --git a/rust/agama-locale-data/src/lib.rs b/rust/agama-locale-data/src/lib.rs index ff85fcb1bd..7b595b3168 100644 --- a/rust/agama-locale-data/src/lib.rs +++ b/rust/agama-locale-data/src/lib.rs @@ -8,6 +8,7 @@ use std::io::BufReader; use std::process::Command; pub mod deprecated_timezones; +pub mod keyboard; pub mod language; mod locale; pub mod localization; From df8d86db94b618b4afa04c4ef8c55e85b92c00ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 22 Nov 2023 11:41:23 +0000 Subject: [PATCH 05/58] Add a ListKeymaps method to the Locale interface --- rust/Cargo.lock | 84 ++++++++++++++++++++++++++ rust/agama-dbus-server/Cargo.toml | 1 + rust/agama-dbus-server/src/helpers.rs | 20 ++++++ rust/agama-dbus-server/src/keyboard.rs | 51 ++++++++++++++++ rust/agama-dbus-server/src/lib.rs | 2 + rust/agama-dbus-server/src/locale.rs | 23 ++++++- rust/agama-dbus-server/src/main.rs | 6 +- 7 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 rust/agama-dbus-server/src/helpers.rs create mode 100644 rust/agama-dbus-server/src/keyboard.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 5f5bde8ad7..0733fa252e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -48,6 +48,7 @@ dependencies = [ "agama-locale-data", "anyhow", "cidr", + "gettext-rs", "log", "serde", "serde_yaml", @@ -394,6 +395,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -894,6 +901,26 @@ dependencies = [ "wasi", ] +[[package]] +name = "gettext-rs" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49ea8a8fad198aaa1f9655a2524b64b70eb06b2f3ff37da407566c93054f364" +dependencies = [ + "gettext-sys", + "locale_config", +] + +[[package]] +name = "gettext-sys" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c63ce2e00f56a206778276704bbe38564c8695249fdc8f354b4ef71c57c3839d" +dependencies = [ + "cc", + "temp-dir", +] + [[package]] name = "gimli" version = "0.28.0" @@ -1082,6 +1109,19 @@ version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "969488b55f8ac402214f3f5fd243ebb7206cf82de60d3172994707a4bcc2b829" +[[package]] +name = "locale_config" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d2c35b16f4483f6c26f0e4e9550717a2f6575bcd6f12a53ff0c490a94a6934" +dependencies = [ + "lazy_static", + "objc", + "objc-foundation", + "regex", + "winapi", +] + [[package]] name = "lock_api" version = "0.4.11" @@ -1101,6 +1141,15 @@ dependencies = [ "value-bag", ] +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.6.4" @@ -1282,6 +1331,35 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + [[package]] name = "object" version = "0.32.1" @@ -1832,6 +1910,12 @@ dependencies = [ "log", ] +[[package]] +name = "temp-dir" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af547b166dd1ea4b472165569fc456cfb6818116f854690b0ff205e636523dab" + [[package]] name = "tempfile" version = "3.8.1" diff --git a/rust/agama-dbus-server/Cargo.toml b/rust/agama-dbus-server/Cargo.toml index 1b28677bcf..7f7ec1c05e 100644 --- a/rust/agama-dbus-server/Cargo.toml +++ b/rust/agama-dbus-server/Cargo.toml @@ -21,3 +21,4 @@ serde_yaml = "0.9.24" cidr = { version = "0.2.2", features = ["serde"] } tokio = { version = "1.33.0", features = ["macros", "rt-multi-thread"] } tokio-stream = "0.1.14" +gettext-rs = { version = "0.7.0", features = ["gettext-system"] } diff --git a/rust/agama-dbus-server/src/helpers.rs b/rust/agama-dbus-server/src/helpers.rs new file mode 100644 index 0000000000..8f7d096d0c --- /dev/null +++ b/rust/agama-dbus-server/src/helpers.rs @@ -0,0 +1,20 @@ +//! Helpers functions +//! +//! FIXME: find a better place for the localization function + +use gettextrs::{bind_textdomain_codeset, setlocale, textdomain, LocaleCategory}; +use std::env; + +pub fn init_locale() -> Result<(), Box> { + let locale = env::var("LANG").unwrap_or("en_US.UTF-8".to_owned()); + set_service_locale(&locale); + textdomain("xkeyboard-config")?; + bind_textdomain_codeset("xkeyboard-config", "UTF-8")?; + Ok(()) +} + +pub fn set_service_locale(locale: &str) { + if setlocale(LocaleCategory::LcAll, locale).is_none() { + log::warn!("Could not set the locale"); + } +} diff --git a/rust/agama-dbus-server/src/keyboard.rs b/rust/agama-dbus-server/src/keyboard.rs new file mode 100644 index 0000000000..04d02265e7 --- /dev/null +++ b/rust/agama-dbus-server/src/keyboard.rs @@ -0,0 +1,51 @@ +use agama_locale_data::keyboard::XkbConfigRegistry; +use gettextrs::*; + +// Minimal representation of a keymap +pub struct Keymap { + layout: String, + variant: Option, + description: String, +} + +impl Keymap { + pub fn new(layout: &str, variant: Option<&str>, description: &str) -> Self { + Self { + layout: layout.to_string(), + variant: variant.map(|v| v.to_string()), + description: description.to_string(), + } + } + + pub fn id(&self) -> String { + if let Some(var) = &self.variant { + format!("{}({})", &self.layout, &var) + } else { + format!("{}", &self.layout) + } + } + + pub fn localized_description(&self) -> String { + gettext(&self.description) + } +} + +pub fn get_xkb_keymaps() -> Vec { + let layouts = XkbConfigRegistry::from_system().unwrap(); + let mut keymaps = vec![]; + + for layout in layouts.layout_list.layouts { + let name = layout.config_item.name; + keymaps.push(Keymap::new(&name, None, &layout.config_item.description)); + + for variant in layout.variants_list.variants { + keymaps.push(Keymap::new( + &name, + Some(&variant.config_item.name), + &variant.config_item.description, + )); + } + } + + keymaps +} diff --git a/rust/agama-dbus-server/src/lib.rs b/rust/agama-dbus-server/src/lib.rs index a4c9dc66c8..91bc755a7c 100644 --- a/rust/agama-dbus-server/src/lib.rs +++ b/rust/agama-dbus-server/src/lib.rs @@ -1,4 +1,6 @@ pub mod error; +pub mod helpers; +pub mod keyboard; pub mod locale; pub mod network; pub mod questions; diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index 6d242b9ff0..0f8ee7ce48 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -1,5 +1,6 @@ -use crate::error::Error; use agama_locale_data::LocaleCode; +use crate::{error::Error, keyboard::Keymap}; +use super::{helpers, keyboard::get_xkb_keymaps}; use anyhow::Context; use std::{fs::read_dir, process::Command}; use zbus::{dbus_interface, Connection}; @@ -10,6 +11,7 @@ pub struct Locale { timezone_id: String, supported_locales: Vec, ui_locale: String, + keymaps: Vec, } #[dbus_interface(name = "org.opensuse.Agama1.Locale")] @@ -106,6 +108,7 @@ impl Locale { #[dbus_interface(property, name = "UILocale")] fn set_ui_locale(&mut self, locale: &str) { self.ui_locale = locale.to_string(); + helpers::set_service_locale(locale); } /// Gets list of locales available on system. @@ -162,6 +165,16 @@ impl Locale { Ok(res) } + #[dbus_interface(name = "ListKeymaps")] + fn list_keymaps(&self) -> Result, Error> { + let keymaps = self + .keymaps + .iter() + .map(|k| (k.id(), k.localized_description())) + .collect(); + Ok(keymaps) + } + #[dbus_interface(property, name = "VConsoleKeyboard")] fn keymap(&self) -> &str { self.keymap.as_str() @@ -238,19 +251,25 @@ impl Locale { let supported: Vec = output.lines().map(|s| s.to_string()).collect(); Ok(Self { supported_locales: supported, + keymaps: get_xkb_keymaps(), ..Default::default() }) } + + pub fn init(&mut self) -> Result<(), Box> { + Ok(()) + } } impl Default for Locale { fn default() -> Self { Self { - locales: vec!["en_US.UTF-8".to_string(), "es_ES.UTF-8".to_string()], + locales: vec!["en_US.UTF-8".to_string()], keymap: "us".to_string(), timezone_id: "America/Los_Angeles".to_string(), supported_locales: vec!["en_US.UTF-8".to_string(), "es_ES.UTF-8".to_string()], ui_locale: "en".to_string(), + keymaps: vec![] } } } diff --git a/rust/agama-dbus-server/src/main.rs b/rust/agama-dbus-server/src/main.rs index cc849d0c1b..cf615fd0a6 100644 --- a/rust/agama-dbus-server/src/main.rs +++ b/rust/agama-dbus-server/src/main.rs @@ -1,8 +1,8 @@ -use agama_dbus_server::{locale, network, questions}; +use agama_dbus_server::{helpers, locale, network, questions}; use agama_lib::connection_to; use anyhow::Context; -use log::LevelFilter; +use log::{self, LevelFilter}; use std::future::pending; use tokio; @@ -11,6 +11,8 @@ const SERVICE_NAME: &str = "org.opensuse.Agama1"; #[tokio::main] async fn main() -> Result<(), Box> { + helpers::init_locale()?; + // be smart with logging and log directly to journal if connected to it if systemd_journal_logger::connected_to_journal() { // unwrap here is intentional as we are sure no other logger is active yet From fcd72ee2f5d5619821a6959fdfc55d819487bba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 22 Nov 2023 12:25:57 +0000 Subject: [PATCH 06/58] Move xkeyboard into the keyboard module --- rust/agama-locale-data/src/keyboard.rs | 2 ++ rust/agama-locale-data/src/{ => keyboard}/xkeyboard.rs | 0 rust/agama-locale-data/src/lib.rs | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) rename rust/agama-locale-data/src/{ => keyboard}/xkeyboard.rs (100%) diff --git a/rust/agama-locale-data/src/keyboard.rs b/rust/agama-locale-data/src/keyboard.rs index ce593d812f..879217dd7d 100644 --- a/rust/agama-locale-data/src/keyboard.rs +++ b/rust/agama-locale-data/src/keyboard.rs @@ -1,3 +1,5 @@ pub mod xkb_config_registry; +pub mod xkeyboard; pub use xkb_config_registry::XkbConfigRegistry; +pub use xkeyboard::XKeyboards; diff --git a/rust/agama-locale-data/src/xkeyboard.rs b/rust/agama-locale-data/src/keyboard/xkeyboard.rs similarity index 100% rename from rust/agama-locale-data/src/xkeyboard.rs rename to rust/agama-locale-data/src/keyboard/xkeyboard.rs diff --git a/rust/agama-locale-data/src/lib.rs b/rust/agama-locale-data/src/lib.rs index 7b595b3168..0b127946fa 100644 --- a/rust/agama-locale-data/src/lib.rs +++ b/rust/agama-locale-data/src/lib.rs @@ -15,7 +15,8 @@ pub mod localization; pub mod ranked; pub mod territory; pub mod timezone_part; -pub mod xkeyboard; + +use keyboard::xkeyboard; pub use locale::{InvalidLocaleCode, LocaleCode}; From 8c31589b664ed35541c28094fd92c070a17091c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 22 Nov 2023 12:52:13 +0000 Subject: [PATCH 07/58] Limit the list of keymaps to the langtable ones --- rust/agama-dbus-server/src/keyboard.rs | 22 ++++++++++++++++++++-- rust/agama-dbus-server/src/locale.rs | 10 +++++----- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/rust/agama-dbus-server/src/keyboard.rs b/rust/agama-dbus-server/src/keyboard.rs index 04d02265e7..a5546ac5f3 100644 --- a/rust/agama-dbus-server/src/keyboard.rs +++ b/rust/agama-dbus-server/src/keyboard.rs @@ -1,4 +1,4 @@ -use agama_locale_data::keyboard::XkbConfigRegistry; +use agama_locale_data::{get_xkeyboards, keyboard::XkbConfigRegistry}; use gettextrs::*; // Minimal representation of a keymap @@ -17,6 +17,9 @@ impl Keymap { } } + /// Returns the ID in the form "layout(variant)" + /// + /// TODO: should we store this ID instead of using separate fields? pub fn id(&self) -> String { if let Some(var) = &self.variant { format!("{}({})", &self.layout, &var) @@ -30,7 +33,22 @@ impl Keymap { } } -pub fn get_xkb_keymaps() -> Vec { +/// Returns the list of keymaps to offer. +/// +/// It only includes the keyboards that are listed in langtable but getting the description +/// from the xkb database. +pub fn get_keymaps() -> Vec { + let xkb_keymaps = get_xkb_keymaps(); + let xkeyboards = get_xkeyboards().unwrap(); + let known_ids: Vec = xkeyboards.keyboard.into_iter().map(|k| k.id).collect(); + xkb_keymaps + .into_iter() + .filter(|k| known_ids.contains(&k.id())) + .collect() +} + +/// Returns the list of keymaps +fn get_xkb_keymaps() -> Vec { let layouts = XkbConfigRegistry::from_system().unwrap(); let mut keymaps = vec![]; diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index 0f8ee7ce48..fe74fb959d 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -1,16 +1,16 @@ -use agama_locale_data::LocaleCode; +use super::{helpers, keyboard::get_keymaps}; use crate::{error::Error, keyboard::Keymap}; -use super::{helpers, keyboard::get_xkb_keymaps}; +use agama_locale_data::LocaleCode; use anyhow::Context; use std::{fs::read_dir, process::Command}; use zbus::{dbus_interface, Connection}; pub struct Locale { locales: Vec, - keymap: String, timezone_id: String, supported_locales: Vec, ui_locale: String, + keymap: String, keymaps: Vec, } @@ -251,7 +251,7 @@ impl Locale { let supported: Vec = output.lines().map(|s| s.to_string()).collect(); Ok(Self { supported_locales: supported, - keymaps: get_xkb_keymaps(), + keymaps: get_keymaps(), ..Default::default() }) } @@ -269,7 +269,7 @@ impl Default for Locale { timezone_id: "America/Los_Angeles".to_string(), supported_locales: vec!["en_US.UTF-8".to_string(), "es_ES.UTF-8".to_string()], ui_locale: "en".to_string(), - keymaps: vec![] + keymaps: vec![], } } } From 9de5a688be6d2b68d108030db62451d5e31bf4f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 22 Nov 2023 13:57:42 +0000 Subject: [PATCH 08/58] [web] WIP locale selector --- web/src/assets/styles/blocks.scss | 34 +++- web/src/components/l10n/L10nPage.jsx | 185 ++++++++++++++------ web/src/components/l10n/LocaleSelector.jsx | 90 ++++++++++ web/src/components/l10n/index.js | 1 + web/src/components/layout/Icon.jsx | 6 + web/src/components/overview/L10nSection.jsx | 2 +- web/src/components/storage/device-utils.jsx | 8 +- 7 files changed, 257 insertions(+), 69 deletions(-) create mode 100644 web/src/components/l10n/LocaleSelector.jsx diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index 0498607f60..d20ce573a4 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -264,7 +264,7 @@ span.notification-mark[data-variant="sidebar"] { } } -.device-list { +.item-list { [role="option"] { padding: var(--spacer-normal); border: 2px solid var(--color-gray-dark); @@ -275,6 +275,21 @@ span.notification-mark[data-variant="sidebar"] { display: grid; gap: var(--spacer-small); + } + + [aria-selected] { + border: 2px solid var(--color-primary); + box-shadow: 0 2px 5px 0 var(--color-gray-dark); + background: var(--color-primary); + color: white; + font-weight: 700; + + svg { + fill: white; + } + } + + .device[role="option"] { grid-template-columns: 1fr 2fr 2fr; grid-template-areas: "type-and-size drive-info drive-content" @@ -287,15 +302,16 @@ span.notification-mark[data-variant="sidebar"] { } } - [aria-selected] { - border: 2px solid var(--color-primary); - box-shadow: 0 2px 5px 0 var(--color-gray-dark); - background: var(--color-primary); - color: white; - font-weight: 700; + .locale[role="option"] { + grid-template-columns: 1fr 2fr 1fr; + grid-template-areas: "id name territory"; - svg { - fill: white; + > :first-child { + text-align: left; + } + + > :last-child { + text-align: right; } } } diff --git a/web/src/components/l10n/L10nPage.jsx b/web/src/components/l10n/L10nPage.jsx index 660c5bf90a..90a73cc113 100644 --- a/web/src/components/l10n/L10nPage.jsx +++ b/web/src/components/l10n/L10nPage.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -19,72 +19,147 @@ * find language contact information at www.suse.com. */ -import React, { useState, useEffect } from "react"; -import { useCancellablePromise } from "~/utils"; +import React, { useState } from "react"; +import { Button, Form } from "@patternfly/react-core"; + import { useInstallerClient } from "~/context/installer"; import { _ } from "~/i18n"; +import { If, Page, Popup, Section } from "~/components/core"; +import { LocaleSelector } from "~/components/l10n"; +import { noop } from "~/utils"; +import { useL10n } from "~/context/l10n"; -import { - Form, - FormGroup, - FormSelect, - FormSelectOption -} from "@patternfly/react-core"; +const TimezoneSection = () => { + return ( +
+

+ TODO +

+
+ ); +}; -import { Page } from "~/components/core"; +/** + * Popup for selecting a locale. + * @component + * + * @param {object} props + * @param {function} props.onFinish - Callback to be called when the locale is correctly selected. + * @param {function} props.onCancel - Callback to be called when the locale selection is canceled. + */ +const LanguagePopup = ({ onFinish = noop, onCancel = noop }) => { + const { l10n } = useInstallerClient(); + const { locales, selectedLocales } = useL10n(); + const [localeId, setLocaleId] = useState(selectedLocales[0]?.id); -const initialState = { - languages: [], - language: "" -}; + const onSubmit = async (e) => { + e.preventDefault(); -export default function LanguageSelector() { - const { language: client } = useInstallerClient(); - const { cancellablePromise } = useCancellablePromise(); - const [state, setState] = useState(initialState); - const { languages, language } = state; + const [locale] = selectedLocales; - const updateState = ({ ...payload }) => { - setState(previousState => ({ ...previousState, ...payload })); - }; + if (localeId !== locale?.id) { + await l10n.setLocales([localeId]); + } - useEffect(() => { - const loadLanguages = async () => { - const languages = await cancellablePromise(client.getLanguages()); - const [language] = await cancellablePromise(client.getSelectedLanguages()); - updateState({ languages, language }); - }; - - loadLanguages().catch(console.error); - }, [client, cancellablePromise]); - - const accept = () => client.setLanguages([language]); - - const LanguageField = ({ selected }) => { - const selectorOptions = languages.map(lang => ( - - )); - - return ( - - updateState({ language: v })} - > - {selectorOptions} - - - ); + onFinish(); }; return ( - // TRANSLATORS: page header - -
- + + + + + + {_("Accept")} + + + + + ); +}; + +const LanguageButton = ({ children }) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + + const openPopup = () => setIsPopupOpen(true); + const closePopup = () => setIsPopupOpen(false); + + return ( + <> + + + + } + /> + + ); +}; + +const LanguageSection = () => { + const { selectedLocales } = useL10n(); + + const [locale] = selectedLocales; + + return ( +
+ +

{locale?.name} - {locale?.territory}

+ {_("Change language")} + + } + else={ + <> +

{_("Language not selected yet")}

+ {_("Select language")} + + } + /> +
+ ); +}; + +const KeyboardSection = () => { + return ( +
+

+ TODO +

+
+ ); +}; + +export default function L10nPage() { + return ( + + + + ); } diff --git a/web/src/components/l10n/LocaleSelector.jsx b/web/src/components/l10n/LocaleSelector.jsx new file mode 100644 index 0000000000..7b8080477b --- /dev/null +++ b/web/src/components/l10n/LocaleSelector.jsx @@ -0,0 +1,90 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; + +import { _ } from "~/i18n"; +import { noop } from "~/utils"; + +/** + * @typedef {import ("~/clients/l10n").Locale} Locale + */ + +const ListBox = ({ children, ...props }) =>
    {children}
; + +const ListBoxItem = ({ isSelected, children, onClick, ...props }) => { + if (isSelected) props['aria-selected'] = true; + + return ( +
  • + {children} +
  • + ); +}; + +/** + * Content for a device item + * @component + * + * @param {Object} props + * @param {Locale} props.locale + */ +const LocaleItem = ({ locale }) => { + return ( + <> +
    {locale.name}
    +
    {locale.territory}
    +
    {locale.id}
    + + ); +}; + +/** + * Component for selecting a locale. + * @component + * + * @param {Object} props + * @param {string} [props.value] - Id of the currently selected locale. + * @param {Locale[]} [props.locales] - Locales for selection. + * @param {(id: string) => void} [props.onChange] - Callback to be called when the selected locale + * changes. + */ +export default function LocaleSelector({ value, locales, onChange = noop }) { + console.log("value: ", value); + return ( + + { locales.map(locale => ( + onChange(locale.id)} + isSelected={locale.id === value} + className="cursor-pointer locale" + > + + + ))} + + ); +} diff --git a/web/src/components/l10n/index.js b/web/src/components/l10n/index.js index dfb127b56e..d0ce623e4b 100644 --- a/web/src/components/l10n/index.js +++ b/web/src/components/l10n/index.js @@ -21,3 +21,4 @@ export { default as L10nPage } from "./L10nPage"; export { default as LanguageSwitcher } from "./LanguageSwitcher"; +export { default as LocaleSelector } from "./LocaleSelector"; diff --git a/web/src/components/layout/Icon.jsx b/web/src/components/layout/Icon.jsx index 42c32748af..53e0f66955 100644 --- a/web/src/components/layout/Icon.jsx +++ b/web/src/components/layout/Icon.jsx @@ -38,11 +38,13 @@ import Error from "@icons/error.svg?component"; import ExpandMore from "@icons/expand_more.svg?component"; import Folder from "@icons/folder.svg?component"; import FolderOff from "@icons/folder_off.svg?component"; +import Globe from "@icons/globe.svg?component"; import HardDrive from "@icons/hard_drive.svg?component"; import Help from "@icons/help.svg?component"; import HomeStorage from "@icons/home_storage.svg?component"; import Info from "@icons/info.svg?component"; import Inventory from "@icons/inventory_2.svg?component"; +import Keyboard from "@icons/keyboard.svg?component"; import Lan from "@icons/lan.svg?component"; import ListAlt from "@icons/list_alt.svg?component"; import Lock from "@icons/lock.svg?component"; @@ -53,6 +55,7 @@ import MoreVert from "@icons/more_vert.svg?component"; import Person from "@icons/person.svg?component"; import Problem from "@icons/problem.svg?component"; import Refresh from "@icons/refresh.svg?component"; +import Schedule from "@icons/schedule.svg?component"; import SettingsApplications from "@icons/settings_applications.svg?component"; import SettingsEthernet from "@icons/settings_ethernet.svg?component"; import SettingsFill from "@icons/settings-fill.svg?component"; @@ -91,11 +94,13 @@ const icons = { expand_more: ExpandMore, folder: Folder, folder_off: FolderOff, + globe: Globe, hard_drive: HardDrive, help: Help, home_storage: HomeStorage, info: Info, inventory_2: Inventory, + keyboard: Keyboard, lan: Lan, loading: Loading, list_alt: ListAlt, @@ -107,6 +112,7 @@ const icons = { person: Person, problem: Problem, refresh: Refresh, + schedule: Schedule, settings: SettingsFill, settings_applications: SettingsApplications, settings_ethernet: SettingsEthernet, diff --git a/web/src/components/overview/L10nSection.jsx b/web/src/components/overview/L10nSection.jsx index 96b47372f2..feb7bb9129 100644 --- a/web/src/components/overview/L10nSection.jsx +++ b/web/src/components/overview/L10nSection.jsx @@ -50,7 +50,7 @@ export default function L10nSection() { // TRANSLATORS: page section title={_("Localization")} loading={isLoading} - icon="translate" + icon="globe" path="/l10n" > { */ const DeviceList = ({ devices }) => { return ( - + { devices.map(device => ( - + ))} @@ -268,13 +268,13 @@ const DeviceSelector = ({ devices, selected, isMultiple = false, onChange = noop }; return ( - + { devices.map(device => ( onOptionClick(device.name)} isSelected={isSelected(device.name)} - className="cursor-pointer" + className="cursor-pointer device" > From 91cfd73739a99f0d26e5ec9ca20dfe7ec0852736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 22 Nov 2023 14:32:07 +0000 Subject: [PATCH 09/58] [web] Sort locales --- web/src/components/l10n/L10nPage.jsx | 21 +++++++++++++-------- web/src/components/l10n/LocaleSelector.jsx | 1 - 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/web/src/components/l10n/L10nPage.jsx b/web/src/components/l10n/L10nPage.jsx index 90a73cc113..b2057f51a5 100644 --- a/web/src/components/l10n/L10nPage.jsx +++ b/web/src/components/l10n/L10nPage.jsx @@ -47,11 +47,16 @@ const TimezoneSection = () => { * @param {function} props.onFinish - Callback to be called when the locale is correctly selected. * @param {function} props.onCancel - Callback to be called when the locale selection is canceled. */ -const LanguagePopup = ({ onFinish = noop, onCancel = noop }) => { +const LocalePopup = ({ onFinish = noop, onCancel = noop }) => { const { l10n } = useInstallerClient(); const { locales, selectedLocales } = useL10n(); const [localeId, setLocaleId] = useState(selectedLocales[0]?.id); + const sortedLocales = locales.sort((locale1, locale2) => { + const localeText = l => [l.name, l.territory].join('').toLowerCase(); + return localeText(locale1) > localeText(locale2) ? 1 : -1; + }); + const onSubmit = async (e) => { e.preventDefault(); @@ -70,7 +75,7 @@ const LanguagePopup = ({ onFinish = noop, onCancel = noop }) => { isOpen >
    - + @@ -82,7 +87,7 @@ const LanguagePopup = ({ onFinish = noop, onCancel = noop }) => { ); }; -const LanguageButton = ({ children }) => { +const LocaleButton = ({ children }) => { const [isPopupOpen, setIsPopupOpen] = useState(false); const openPopup = () => setIsPopupOpen(true); @@ -101,7 +106,7 @@ const LanguageButton = ({ children }) => { { ); }; -const LanguageSection = () => { +const LocaleSection = () => { const { selectedLocales } = useL10n(); const [locale] = selectedLocales; @@ -124,13 +129,13 @@ const LanguageSection = () => { then={ <>

    {locale?.name} - {locale?.territory}

    - {_("Change language")} + {_("Change language")} } else={ <>

    {_("Language not selected yet")}

    - {_("Select language")} + {_("Select language")} } /> @@ -158,7 +163,7 @@ export default function L10nPage() { actionVariant="secondary" > - +
    ); diff --git a/web/src/components/l10n/LocaleSelector.jsx b/web/src/components/l10n/LocaleSelector.jsx index 7b8080477b..cb42ed299e 100644 --- a/web/src/components/l10n/LocaleSelector.jsx +++ b/web/src/components/l10n/LocaleSelector.jsx @@ -72,7 +72,6 @@ const LocaleItem = ({ locale }) => { * changes. */ export default function LocaleSelector({ value, locales, onChange = noop }) { - console.log("value: ", value); return ( { locales.map(locale => ( From 21726d02c545daf8d6b9e1f9f74e997bb12660ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 22 Nov 2023 15:11:02 +0000 Subject: [PATCH 10/58] Refactor ListKeymaps to start by the langtable --- rust/agama-dbus-server/src/keyboard.rs | 60 ++++++++++++-------------- rust/agama-dbus-server/src/locale.rs | 2 +- 2 files changed, 28 insertions(+), 34 deletions(-) diff --git a/rust/agama-dbus-server/src/keyboard.rs b/rust/agama-dbus-server/src/keyboard.rs index a5546ac5f3..b5ad8f3a2d 100644 --- a/rust/agama-dbus-server/src/keyboard.rs +++ b/rust/agama-dbus-server/src/keyboard.rs @@ -1,33 +1,22 @@ +use std::collections::HashMap; + use agama_locale_data::{get_xkeyboards, keyboard::XkbConfigRegistry}; use gettextrs::*; // Minimal representation of a keymap pub struct Keymap { - layout: String, - variant: Option, + pub id: String, description: String, } impl Keymap { - pub fn new(layout: &str, variant: Option<&str>, description: &str) -> Self { + pub fn new(layout: &str, description: &str) -> Self { Self { - layout: layout.to_string(), - variant: variant.map(|v| v.to_string()), + id: layout.to_string(), description: description.to_string(), } } - /// Returns the ID in the form "layout(variant)" - /// - /// TODO: should we store this ID instead of using separate fields? - pub fn id(&self) -> String { - if let Some(var) = &self.variant { - format!("{}({})", &self.layout, &var) - } else { - format!("{}", &self.layout) - } - } - pub fn localized_description(&self) -> String { gettext(&self.description) } @@ -35,33 +24,38 @@ impl Keymap { /// Returns the list of keymaps to offer. /// -/// It only includes the keyboards that are listed in langtable but getting the description -/// from the xkb database. +/// It only includes the keyboards that are listed in langtable but getting the +/// description from the X Keyboard Configuration Database. pub fn get_keymaps() -> Vec { - let xkb_keymaps = get_xkb_keymaps(); + let mut keymaps: Vec = vec![]; + let xkb_descriptions= get_keymap_descriptions(); let xkeyboards = get_xkeyboards().unwrap(); - let known_ids: Vec = xkeyboards.keyboard.into_iter().map(|k| k.id).collect(); - xkb_keymaps - .into_iter() - .filter(|k| known_ids.contains(&k.id())) - .collect() + for keyboard in xkeyboards.keyboard { + if let Some(description) = xkb_descriptions.get(&keyboard.id) { + keymaps.push(Keymap::new( + &keyboard.id, description + )); + } else { + log::debug!("Keyboard '{}' not found in xkb database", keyboard.id); + } + } + + keymaps } -/// Returns the list of keymaps -fn get_xkb_keymaps() -> Vec { +/// Returns a map of keymaps ids and its descriptions from the X Keyboard +/// Configuration Database. +fn get_keymap_descriptions() -> HashMap { let layouts = XkbConfigRegistry::from_system().unwrap(); - let mut keymaps = vec![]; + let mut keymaps = HashMap::new(); for layout in layouts.layout_list.layouts { let name = layout.config_item.name; - keymaps.push(Keymap::new(&name, None, &layout.config_item.description)); + keymaps.insert(name.to_string(), layout.config_item.description.to_string()); for variant in layout.variants_list.variants { - keymaps.push(Keymap::new( - &name, - Some(&variant.config_item.name), - &variant.config_item.description, - )); + let id = format!("{}({})", &name, &variant.config_item.name); + keymaps.insert(id, variant.config_item.description); } } diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index fe74fb959d..2743ee1fcd 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -170,7 +170,7 @@ impl Locale { let keymaps = self .keymaps .iter() - .map(|k| (k.id(), k.localized_description())) + .map(|k| (k.id.to_owned(), k.localized_description())) .collect(); Ok(keymaps) } From d53cd0a490d6c69fef7a10a52f42717e2fc28ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 22 Nov 2023 16:37:19 +0000 Subject: [PATCH 11/58] ListTimezones uses an array for the translated parts --- rust/agama-dbus-server/src/locale.rs | 21 +++++++++++++++++---- rust/agama-locale-data/src/timezone_part.rs | 8 ++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index 2743ee1fcd..5ee064c823 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -195,11 +195,24 @@ impl Locale { Ok(()) } - fn list_timezones(&self, locale: &str) -> Result, Error> { + fn list_timezones(&self) -> Result)>, Error> { + let language = self.ui_locale.split("_").next().unwrap_or(&self.ui_locale); let timezones = agama_locale_data::get_timezones(); - let localized = - agama_locale_data::get_timezone_parts()?.localize_timezones(locale, &timezones); - let ret = timezones.into_iter().zip(localized.into_iter()).collect(); + let tz_parts = agama_locale_data::get_timezone_parts()?; + let ret = timezones + .into_iter() + .map(|tz| { + let parts: Vec<_> = tz + .split("/") + .map(|part| { + tz_parts + .localize_part(part, &language) + .unwrap_or(part.to_owned()) + }) + .collect(); + (tz, parts) + }) + .collect(); Ok(ret) } diff --git a/rust/agama-locale-data/src/timezone_part.rs b/rust/agama-locale-data/src/timezone_part.rs index c1390c3815..1f1d1223e6 100644 --- a/rust/agama-locale-data/src/timezone_part.rs +++ b/rust/agama-locale-data/src/timezone_part.rs @@ -20,6 +20,14 @@ pub struct TimezoneIdParts { } impl TimezoneIdParts { + // TODO: Implement a caching mechanism + pub fn localize_part(&self, part_id: &str, language: &str) -> Option { + self.timezone_part + .iter() + .find(|p| p.id == part_id) + .and_then(|p| p.names.name_for(language)) + } + /// Localized given list of timezones to given language /// # Examples /// From 7747363e3772bf6e746b78d2738a3123424220d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 22 Nov 2023 16:38:22 +0000 Subject: [PATCH 12/58] Make rustfmt happy --- rust/agama-dbus-server/src/keyboard.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/rust/agama-dbus-server/src/keyboard.rs b/rust/agama-dbus-server/src/keyboard.rs index b5ad8f3a2d..7fcb41887f 100644 --- a/rust/agama-dbus-server/src/keyboard.rs +++ b/rust/agama-dbus-server/src/keyboard.rs @@ -28,13 +28,11 @@ impl Keymap { /// description from the X Keyboard Configuration Database. pub fn get_keymaps() -> Vec { let mut keymaps: Vec = vec![]; - let xkb_descriptions= get_keymap_descriptions(); + let xkb_descriptions = get_keymap_descriptions(); let xkeyboards = get_xkeyboards().unwrap(); for keyboard in xkeyboards.keyboard { if let Some(description) = xkb_descriptions.get(&keyboard.id) { - keymaps.push(Keymap::new( - &keyboard.id, description - )); + keymaps.push(Keymap::new(&keyboard.id, description)); } else { log::debug!("Keyboard '{}' not found in xkb database", keyboard.id); } From 9457bdbc9caf828ca0a5546ef718f0774d5cc206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 22 Nov 2023 16:46:01 +0000 Subject: [PATCH 13/58] Replace VConsoleKeyboard with Keymap --- rust/agama-dbus-server/src/locale.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index 5ee064c823..f5bb5f1202 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -175,23 +175,19 @@ impl Locale { Ok(keymaps) } - #[dbus_interface(property, name = "VConsoleKeyboard")] + #[dbus_interface(property)] fn keymap(&self) -> &str { self.keymap.as_str() } - #[dbus_interface(property, name = "VConsoleKeyboard")] - fn set_keymap(&mut self, keyboard: &str) -> Result<(), zbus::fdo::Error> { - let exist = agama_locale_data::get_key_maps() - .unwrap() - .iter() - .any(|k| k == keyboard); - if !exist { + #[dbus_interface(property)] + fn set_keymap(&mut self, keymap_id: &str) -> Result<(), zbus::fdo::Error> { + if !self.keymaps.iter().any(|k| k.id == keymap_id) { return Err(zbus::fdo::Error::Failed( "Invalid keyboard value".to_string(), )); } - self.keymap = keyboard.to_string(); + self.keymap = keymap_id.to_string(); Ok(()) } @@ -278,10 +274,10 @@ impl Default for Locale { fn default() -> Self { Self { locales: vec!["en_US.UTF-8".to_string()], - keymap: "us".to_string(), timezone_id: "America/Los_Angeles".to_string(), supported_locales: vec!["en_US.UTF-8".to_string(), "es_ES.UTF-8".to_string()], ui_locale: "en".to_string(), + keymap: "us".to_string(), keymaps: vec![], } } From c7d0ceb17f3ad9ae946e389fb78698ff970e4ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 23 Nov 2023 08:32:34 +0000 Subject: [PATCH 14/58] Model the keyboard identifier as a type --- rust/Cargo.lock | 2 + rust/agama-dbus-server/Cargo.toml | 2 + rust/agama-dbus-server/src/keyboard.rs | 14 ++-- rust/agama-dbus-server/src/locale.rs | 27 ++++---- rust/agama-locale-data/src/lib.rs | 6 +- rust/agama-locale-data/src/locale.rs | 96 ++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 24 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 0733fa252e..4c34f4a9b7 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -50,6 +50,8 @@ dependencies = [ "cidr", "gettext-rs", "log", + "once_cell", + "regex", "serde", "serde_yaml", "simplelog", diff --git a/rust/agama-dbus-server/Cargo.toml b/rust/agama-dbus-server/Cargo.toml index 7f7ec1c05e..368096cdfa 100644 --- a/rust/agama-dbus-server/Cargo.toml +++ b/rust/agama-dbus-server/Cargo.toml @@ -22,3 +22,5 @@ cidr = { version = "0.2.2", features = ["serde"] } tokio = { version = "1.33.0", features = ["macros", "rt-multi-thread"] } tokio-stream = "0.1.14" gettext-rs = { version = "0.7.0", features = ["gettext-system"] } +regex = "1.10.2" +once_cell = "1.18.0" diff --git a/rust/agama-dbus-server/src/keyboard.rs b/rust/agama-dbus-server/src/keyboard.rs index 7fcb41887f..537e048668 100644 --- a/rust/agama-dbus-server/src/keyboard.rs +++ b/rust/agama-dbus-server/src/keyboard.rs @@ -1,18 +1,17 @@ -use std::collections::HashMap; - -use agama_locale_data::{get_xkeyboards, keyboard::XkbConfigRegistry}; +use agama_locale_data::{get_xkeyboards, keyboard::XkbConfigRegistry, KeymapId}; use gettextrs::*; +use std::collections::HashMap; // Minimal representation of a keymap pub struct Keymap { - pub id: String, + pub id: KeymapId, description: String, } impl Keymap { - pub fn new(layout: &str, description: &str) -> Self { + pub fn new(id: KeymapId, description: &str) -> Self { Self { - id: layout.to_string(), + id, description: description.to_string(), } } @@ -31,8 +30,9 @@ pub fn get_keymaps() -> Vec { let xkb_descriptions = get_keymap_descriptions(); let xkeyboards = get_xkeyboards().unwrap(); for keyboard in xkeyboards.keyboard { + let keymap_id = keyboard.id.parse::().unwrap(); if let Some(description) = xkb_descriptions.get(&keyboard.id) { - keymaps.push(Keymap::new(&keyboard.id, description)); + keymaps.push(Keymap::new(keymap_id, description)); } else { log::debug!("Keyboard '{}' not found in xkb database", keyboard.id); } diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index f5bb5f1202..55332a76d0 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -1,6 +1,6 @@ use super::{helpers, keyboard::get_keymaps}; use crate::{error::Error, keyboard::Keymap}; -use agama_locale_data::LocaleCode; +use agama_locale_data::{KeymapId, LocaleCode}; use anyhow::Context; use std::{fs::read_dir, process::Command}; use zbus::{dbus_interface, Connection}; @@ -10,7 +10,7 @@ pub struct Locale { timezone_id: String, supported_locales: Vec, ui_locale: String, - keymap: String, + keymap: KeymapId, keymaps: Vec, } @@ -159,35 +159,33 @@ impl Locale { } */ - #[dbus_interface(name = "ListVConsoleKeyboards")] - fn list_keyboards(&self) -> Result, Error> { - let res = agama_locale_data::get_key_maps()?; - Ok(res) - } - #[dbus_interface(name = "ListKeymaps")] fn list_keymaps(&self) -> Result, Error> { let keymaps = self .keymaps .iter() - .map(|k| (k.id.to_owned(), k.localized_description())) + .map(|k| (k.id.to_string(), k.localized_description())) .collect(); Ok(keymaps) } #[dbus_interface(property)] - fn keymap(&self) -> &str { - self.keymap.as_str() + fn keymap(&self) -> String { + self.keymap.to_string() } #[dbus_interface(property)] fn set_keymap(&mut self, keymap_id: &str) -> Result<(), zbus::fdo::Error> { + let keymap_id: KeymapId = keymap_id + .parse() + .map_err(|_e| zbus::fdo::Error::InvalidArgs("Invalid keymap".to_string()))?; + if !self.keymaps.iter().any(|k| k.id == keymap_id) { return Err(zbus::fdo::Error::Failed( "Invalid keyboard value".to_string(), )); } - self.keymap = keymap_id.to_string(); + self.keymap = keymap_id; Ok(()) } @@ -236,8 +234,9 @@ impl Locale { ]) .status() .context("Failed to execute systemd-firstboot")?; + let keymap = self.keymap.to_string(); Command::new("/usr/bin/systemd-firstboot") - .args(["root", ROOT, "--keymap", self.keymap.as_str()]) + .args(["root", ROOT, "--keymap", &keymap]) .status() .context("Failed to execute systemd-firstboot")?; Command::new("/usr/bin/systemd-firstboot") @@ -277,7 +276,7 @@ impl Default for Locale { timezone_id: "America/Los_Angeles".to_string(), supported_locales: vec!["en_US.UTF-8".to_string(), "es_ES.UTF-8".to_string()], ui_locale: "en".to_string(), - keymap: "us".to_string(), + keymap: "us".parse().unwrap(), keymaps: vec![], } } diff --git a/rust/agama-locale-data/src/lib.rs b/rust/agama-locale-data/src/lib.rs index 0b127946fa..21c771960f 100644 --- a/rust/agama-locale-data/src/lib.rs +++ b/rust/agama-locale-data/src/lib.rs @@ -18,7 +18,7 @@ pub mod timezone_part; use keyboard::xkeyboard; -pub use locale::{InvalidLocaleCode, LocaleCode}; +pub use locale::{InvalidLocaleCode, KeymapId, LocaleCode}; fn file_reader(file_path: &str) -> anyhow::Result { let file = File::open(file_path) @@ -46,7 +46,7 @@ pub fn get_xkeyboards() -> anyhow::Result { /// let key_maps = agama_locale_data::get_key_maps().unwrap(); /// assert!(key_maps.contains(&"us".to_string())) /// ``` -pub fn get_key_maps() -> anyhow::Result> { +pub fn get_localectl_keymaps() -> anyhow::Result> { const BINARY: &str = "/usr/bin/localectl"; let output = Command::new(BINARY) .arg("list-keymaps") @@ -54,7 +54,7 @@ pub fn get_key_maps() -> anyhow::Result> { .context("failed to execute localectl list-maps")? .stdout; let output = String::from_utf8(output).context("Strange localectl output formatting")?; - let ret = output.split('\n').map(|l| l.trim().to_string()).collect(); + let ret: Vec<_> = output.lines().flat_map(|l| l.parse().ok()).collect(); Ok(ret) } diff --git a/rust/agama-locale-data/src/locale.rs b/rust/agama-locale-data/src/locale.rs index b1cce89ac8..99764e3b71 100644 --- a/rust/agama-locale-data/src/locale.rs +++ b/rust/agama-locale-data/src/locale.rs @@ -1,4 +1,8 @@ +//! Defines useful types to deal with localization values + use regex::Regex; +use std::sync::OnceLock; +use std::{fmt::Display, str::FromStr}; use thiserror::Error; pub struct LocaleCode { @@ -28,3 +32,95 @@ impl TryFrom<&str> for LocaleCode { }) } } + +static KEYMAP_ID_REGEX: OnceLock = OnceLock::new(); + +/// Keymap layout identifier +/// +/// ``` +/// use KeymapId; +/// use std::str::FromStr; +/// +/// let id: KeymapId = "es(ast)".parse(); +/// assert_eq!(&id.layout, "es"); +/// assert_eq!(&id.variant, "ast"); +/// assert_eq!(id.dashed(), "es-ast".to_string()); +/// +/// let id_with_dashes: KeymapId = "es-ast".parse(); +/// assert_eq!(id, id_with_dashes); +/// ``` +#[derive(Clone, Debug, PartialEq)] +pub struct KeymapId { + pub layout: String, + pub variant: Option +} + +#[derive(Error, Debug)] +#[error("Invalid keymap ID: {0}")] +pub struct InvalidKeymap(String); + +impl KeymapId { + pub fn dashed(&self) -> String { + if let Some(variant) = &self.variant { + format!("{}-{}", &self.layout, variant) + } else { + self.layout.to_owned() + } + } +} + +impl Display for KeymapId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(variant) = &self.variant { + write!(f, "{}({})", &self.layout, variant) + } else { + write!(f, "{}", &self.layout) + } + } +} + +impl FromStr for KeymapId { + type Err = InvalidKeymap; + + fn from_str(s: &str) -> Result { + let re = KEYMAP_ID_REGEX + .get_or_init(|| Regex::new(r"(\w+)((\((?\w+)\)|-(?\w+)))?").unwrap()); + + if let Some(parts) = re.captures(s) { + let mut variant = None; + if let Some(var1) = parts.name("var1") { + variant = Some(var1.as_str().to_string()); + } + if let Some(var2) = parts.name("var2") { + variant = Some(var2.as_str().to_string()); + } + Ok(KeymapId { layout: parts[1].to_string(), variant }) + } else { + Err(InvalidKeymap(s.to_string())) + } + } +} + +#[cfg(test)] +mod test { + use super::KeymapId; + use std::str::FromStr; + + #[test] + fn test_parse_keymap_id() { + let keymap_id0 = KeymapId::from_str("es").unwrap(); + assert_eq!(KeymapId { layout: "es".to_string(), variant: None }, keymap_id0); + + let keymap_id1 = KeymapId::from_str("es(ast)").unwrap(); + assert_eq!( + KeymapId { layout: "es".to_string(), variant: Some("ast".to_string()) }, + keymap_id1 + ); + + let keymap_id2 = KeymapId::from_str("es-ast").unwrap(); + assert_eq!( + KeymapId { layout: "es".to_string(), variant: Some("ast".to_string()) }, + keymap_id2 + ); + } +} From d6f7e9c96a585a1041ee6df0bd5aec2511bb7f0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 23 Nov 2023 10:13:59 +0000 Subject: [PATCH 15/58] Fix systemd-firstboot invocation --- rust/agama-dbus-server/src/locale.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index 55332a76d0..81733e1757 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -227,20 +227,19 @@ impl Locale { const ROOT: &str = "/mnt"; Command::new("/usr/bin/systemd-firstboot") .args([ - "root", + "--root", ROOT, "--locale", self.locales.first().context("missing locale")?.as_str(), ]) .status() .context("Failed to execute systemd-firstboot")?; - let keymap = self.keymap.to_string(); Command::new("/usr/bin/systemd-firstboot") - .args(["root", ROOT, "--keymap", &keymap]) + .args(["--root", ROOT, "--keymap", &self.keymap.to_string()]) .status() .context("Failed to execute systemd-firstboot")?; Command::new("/usr/bin/systemd-firstboot") - .args(["root", ROOT, "--timezone", self.timezone_id.as_str()]) + .args(["--root", ROOT, "--timezone", self.timezone_id.as_str()]) .status() .context("Failed to execute systemd-firstboot")?; From d0434d57ad73d08ed1e8bdcc35f6a5cdf3be91d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 23 Nov 2023 10:32:39 +0000 Subject: [PATCH 16/58] Fix dbus-server and locale-data tests --- rust/agama-dbus-server/src/locale.rs | 2 +- rust/agama-locale-data/src/lib.rs | 7 ++++-- rust/agama-locale-data/src/locale.rs | 33 ++++++++++++++++++++-------- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index 81733e1757..be3d1027b8 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -117,7 +117,7 @@ impl Locale { /// /// ``` /// use agama_dbus_server::locale::Locale; - /// let locale = Locale::new(); + /// let locale = Locale::default(); /// assert!(locale.list_ui_locales().unwrap().len() > 0); /// ``` #[dbus_interface(name = "ListUILocales")] diff --git a/rust/agama-locale-data/src/lib.rs b/rust/agama-locale-data/src/lib.rs index 21c771960f..e1a48914d0 100644 --- a/rust/agama-locale-data/src/lib.rs +++ b/rust/agama-locale-data/src/lib.rs @@ -43,8 +43,11 @@ pub fn get_xkeyboards() -> anyhow::Result { /// Requires working localectl. /// /// ```no_run -/// let key_maps = agama_locale_data::get_key_maps().unwrap(); -/// assert!(key_maps.contains(&"us".to_string())) +/// use agama_locale_data::KeymapId; +/// +/// let key_maps = agama_locale_data::get_localectl_keymaps().unwrap(); +/// let us: KeymapId = "us".parse().unwrap(); +/// assert!(key_maps.contains(&us)); /// ``` pub fn get_localectl_keymaps() -> anyhow::Result> { const BINARY: &str = "/usr/bin/localectl"; diff --git a/rust/agama-locale-data/src/locale.rs b/rust/agama-locale-data/src/locale.rs index 99764e3b71..e8f4c4882b 100644 --- a/rust/agama-locale-data/src/locale.rs +++ b/rust/agama-locale-data/src/locale.rs @@ -38,21 +38,21 @@ static KEYMAP_ID_REGEX: OnceLock = OnceLock::new(); /// Keymap layout identifier /// /// ``` -/// use KeymapId; +/// use agama_locale_data::KeymapId; /// use std::str::FromStr; /// -/// let id: KeymapId = "es(ast)".parse(); +/// let id: KeymapId = "es(ast)".parse().unwrap(); /// assert_eq!(&id.layout, "es"); -/// assert_eq!(&id.variant, "ast"); +/// assert_eq!(id.variant.clone(), Some("ast".to_string())); /// assert_eq!(id.dashed(), "es-ast".to_string()); /// -/// let id_with_dashes: KeymapId = "es-ast".parse(); +/// let id_with_dashes: KeymapId = "es-ast".parse().unwrap(); /// assert_eq!(id, id_with_dashes); /// ``` #[derive(Clone, Debug, PartialEq)] pub struct KeymapId { pub layout: String, - pub variant: Option + pub variant: Option, } #[derive(Error, Debug)] @@ -94,7 +94,10 @@ impl FromStr for KeymapId { if let Some(var2) = parts.name("var2") { variant = Some(var2.as_str().to_string()); } - Ok(KeymapId { layout: parts[1].to_string(), variant }) + Ok(KeymapId { + layout: parts[1].to_string(), + variant, + }) } else { Err(InvalidKeymap(s.to_string())) } @@ -109,17 +112,29 @@ mod test { #[test] fn test_parse_keymap_id() { let keymap_id0 = KeymapId::from_str("es").unwrap(); - assert_eq!(KeymapId { layout: "es".to_string(), variant: None }, keymap_id0); + assert_eq!( + KeymapId { + layout: "es".to_string(), + variant: None + }, + keymap_id0 + ); let keymap_id1 = KeymapId::from_str("es(ast)").unwrap(); assert_eq!( - KeymapId { layout: "es".to_string(), variant: Some("ast".to_string()) }, + KeymapId { + layout: "es".to_string(), + variant: Some("ast".to_string()) + }, keymap_id1 ); let keymap_id2 = KeymapId::from_str("es-ast").unwrap(); assert_eq!( - KeymapId { layout: "es".to_string(), variant: Some("ast".to_string()) }, + KeymapId { + layout: "es".to_string(), + variant: Some("ast".to_string()) + }, keymap_id2 ); } From 9d06d1c2cc2a9e56adb4dca01c746133729dc512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 23 Nov 2023 11:29:29 +0000 Subject: [PATCH 17/58] Use only keymaps from the "localectl list-keymaps" --- rust/agama-dbus-server/src/keyboard.rs | 20 ++++++++++---------- rust/agama-dbus-server/src/locale.rs | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/rust/agama-dbus-server/src/keyboard.rs b/rust/agama-dbus-server/src/keyboard.rs index 537e048668..449b632f87 100644 --- a/rust/agama-dbus-server/src/keyboard.rs +++ b/rust/agama-dbus-server/src/keyboard.rs @@ -1,4 +1,4 @@ -use agama_locale_data::{get_xkeyboards, keyboard::XkbConfigRegistry, KeymapId}; +use agama_locale_data::{get_localectl_keymaps, keyboard::XkbConfigRegistry, KeymapId}; use gettextrs::*; use std::collections::HashMap; @@ -23,22 +23,22 @@ impl Keymap { /// Returns the list of keymaps to offer. /// -/// It only includes the keyboards that are listed in langtable but getting the -/// description from the X Keyboard Configuration Database. -pub fn get_keymaps() -> Vec { +/// It only includes the keyboards supported by `localectl` but getting +/// the description from the X Keyboard Configuration Database. +pub fn get_keymaps() -> anyhow::Result> { let mut keymaps: Vec = vec![]; let xkb_descriptions = get_keymap_descriptions(); - let xkeyboards = get_xkeyboards().unwrap(); - for keyboard in xkeyboards.keyboard { - let keymap_id = keyboard.id.parse::().unwrap(); - if let Some(description) = xkb_descriptions.get(&keyboard.id) { + let keymap_ids = get_localectl_keymaps()?; + for keymap_id in keymap_ids { + let keymap_id_str = keymap_id.to_string(); + if let Some(description) = xkb_descriptions.get(&keymap_id_str) { keymaps.push(Keymap::new(keymap_id, description)); } else { - log::debug!("Keyboard '{}' not found in xkb database", keyboard.id); + log::debug!("Keyboard '{}' not found in xkb database", keymap_id_str); } } - keymaps + Ok(keymaps) } /// Returns a map of keymaps ids and its descriptions from the X Keyboard diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index be3d1027b8..83b064b660 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -258,7 +258,7 @@ impl Locale { let supported: Vec = output.lines().map(|s| s.to_string()).collect(); Ok(Self { supported_locales: supported, - keymaps: get_keymaps(), + keymaps: get_keymaps()?, ..Default::default() }) } From 424eb21ce2d15ff6d8a9af784ff5d7d66331fa27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 23 Nov 2023 08:49:26 +0000 Subject: [PATCH 18/58] [web] Keymap selector --- web/src/assets/styles/blocks.scss | 12 +++ web/src/client/l10n.js | 66 +++++++------ web/src/components/l10n/KeymapSelector.jsx | 88 ++++++++++++++++++ web/src/components/l10n/L10nPage.jsx | 103 +++++++++++++++++++-- web/src/components/l10n/index.js | 1 + web/src/context/l10n.jsx | 29 +++++- 6 files changed, 261 insertions(+), 38 deletions(-) create mode 100644 web/src/components/l10n/KeymapSelector.jsx diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index d20ce573a4..0439805a62 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -275,6 +275,12 @@ span.notification-mark[data-variant="sidebar"] { display: grid; gap: var(--spacer-small); + + &:hover { + &:not([aria-selected]) { + background: var(--color-gray-dark); + } + } } [aria-selected] { @@ -313,6 +319,12 @@ span.notification-mark[data-variant="sidebar"] { > :last-child { text-align: right; } + + &:not([aria-selected]) { + > :last-child { + color: var(--color-gray-darker); + } + } } } diff --git a/web/src/client/l10n.js b/web/src/client/l10n.js index 687ef68f0b..b04f613c57 100644 --- a/web/src/client/l10n.js +++ b/web/src/client/l10n.js @@ -40,10 +40,9 @@ const LOCALE_PATH = "/org/opensuse/Agama1/Locale"; */ /** - * @typedef {object} Keyboard + * @typedef {object} Keymap * @property {string} id - Keyboard id (e.g., "us"). - * @property {string} name - Keyboard name (e.g., "English"). - * @property {string} territory - Territory name (e.g., "United States"). + * @property {string} name - Keyboard name (e.g., "English (US)"). */ /** @@ -162,47 +161,47 @@ class L10nClient { } /** - * Available keyboards to install in the target system. + * Available keymaps to install in the target system. * - * Note that name and territory are localized to the current selected UI language: - * { id: "es", name: "Spanish", territory: "Spain" } + * Note that name is localized to the current selected UI language: + * { id: "es", name: "Spanish (ES)" } * - * @return {Promise>} + * @return {Promise>} */ - async keyboards() { + async keymaps() { const proxy = await this.client.proxy(LOCALE_IFACE); - const keyboards = await proxy.ListVConsoleKeyboards(); + const keymaps = await proxy.ListKeymaps(); - // TODO: D-Bus currently returns the id only - return keyboards.map(id => this.buildKeyboard([id, "", ""])); + return keymaps.map(this.buildKeymap); } /** - * Keyboard selected to install in the target system. + * Keymap selected to install in the target system. * - * @return {Promise} Id of the keyboard. + * @return {Promise} Id of the keymap. */ - async getKeyboard() { + async getKeymap() { const proxy = await this.client.proxy(LOCALE_IFACE); - return proxy.VConsoleKeyboard; + return proxy.Keymap; } /** - * Sets the keyboard to install in the target system. + * Sets the keymap to install in the target system. * - * @param {string} id - Id of the keyboard. + * @param {string} id - Id of the keymap. * @return {Promise} */ - async setKeyboard(id) { + async setKeymap(id) { const proxy = await this.client.proxy(LOCALE_IFACE); - proxy.VConsoleKeyboard = id; + + proxy.Keymap = id; } /** - * Register a callback to run when properties in the Language object change + * Register a callback to run when Locales D-Bus property changes. * - * @param {(language: string) => void} handler - function to call when the language change - * @return {import ("./dbus").RemoveFn} function to disable the callback + * @param {(language: string) => void} handler - Function to call when Locales changes. + * @return {import ("./dbus").RemoveFn} Function to disable the callback. */ onLocalesChange(handler) { return this.client.onObjectChanged(LOCALE_PATH, LOCALE_IFACE, changes => { @@ -213,6 +212,21 @@ class L10nClient { }); } + /** + * Register a callback to run when Keymap D-Bus property changes. + * + * @param {(language: string) => void} handler - Function to call when Keymap changes. + * @return {import ("./dbus").RemoveFn} Function to disable the callback. + */ + onKeymapChange(handler) { + return this.client.onObjectChanged(LOCALE_PATH, LOCALE_IFACE, changes => { + if ("Keymap" in changes) { + const id = changes.Keymap.v; + handler(id); + } + }); + } + /** * @private * @@ -236,11 +250,11 @@ class L10nClient { /** * @private * - * @param {[string, string, string]} dbusKeyboard - * @returns {Keyboard} + * @param {[string, string]} dbusKeymap + * @returns {Keymap} */ - buildKeyboard([id, name, territory]) { - return ({ id, name, territory }); + buildKeymap([id, name]) { + return ({ id, name }); } } diff --git a/web/src/components/l10n/KeymapSelector.jsx b/web/src/components/l10n/KeymapSelector.jsx new file mode 100644 index 0000000000..d7956c93b0 --- /dev/null +++ b/web/src/components/l10n/KeymapSelector.jsx @@ -0,0 +1,88 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; + +import { _ } from "~/i18n"; +import { noop } from "~/utils"; + +/** + * @typedef {import ("~/clients/l10n").Keymap} Keymap + */ + +const ListBox = ({ children, ...props }) =>
      {children}
    ; + +const ListBoxItem = ({ isSelected, children, onClick, ...props }) => { + if (isSelected) props['aria-selected'] = true; + + return ( +
  • + {children} +
  • + ); +}; + +/** + * Content for a keymap item + * @component + * + * @param {Object} props + * @param {Keymap} props.keymap + */ +const KeymapItem = ({ keymap }) => { + return ( + <> +
    {keymap.name}
    +
    {keymap.id}
    + + ); +}; + +/** + * Component for selecting a keymap. + * @component + * + * @param {Object} props + * @param {string} [props.value] - Id of the currently selected keymap. + * @param {Keymap[]} [props.keymap] - Keymaps for selection. + * @param {(id: string) => void} [props.onChange] - Callback to be called when the selected keymap + * changes. + */ +export default function KeymapSelector({ value, keymaps = [], onChange = noop }) { + return ( + + { keymaps.map(keymap => ( + onChange(keymap.id)} + isSelected={keymap.id === value} + className="cursor-pointer" + > + + + ))} + + ); +} diff --git a/web/src/components/l10n/L10nPage.jsx b/web/src/components/l10n/L10nPage.jsx index b2057f51a5..7913e2fada 100644 --- a/web/src/components/l10n/L10nPage.jsx +++ b/web/src/components/l10n/L10nPage.jsx @@ -25,7 +25,7 @@ import { Button, Form } from "@patternfly/react-core"; import { useInstallerClient } from "~/context/installer"; import { _ } from "~/i18n"; import { If, Page, Popup, Section } from "~/components/core"; -import { LocaleSelector } from "~/components/l10n"; +import { KeymapSelector, LocaleSelector } from "~/components/l10n"; import { noop } from "~/utils"; import { useL10n } from "~/context/l10n"; @@ -71,8 +71,9 @@ const LocalePopup = ({ onFinish = noop, onCancel = noop }) => { return (
    @@ -143,12 +144,100 @@ const LocaleSection = () => { ); }; -const KeyboardSection = () => { +/** + * Popup for selecting a keymap. + * @component + * + * @param {object} props + * @param {function} props.onFinish - Callback to be called when the keymap is correctly selected. + * @param {function} props.onCancel - Callback to be called when the keymap selection is canceled. + */ +const KeymapPopup = ({ onFinish = noop, onCancel = noop }) => { + const { l10n } = useInstallerClient(); + const { keymaps, selectedKeymap } = useL10n(); + const [keymapId, setKeymapId] = useState(selectedKeymap?.id); + + const sortedKeymaps = keymaps.sort((k1, k2) => k1.name > k2.name ? 1 : -1); + + const onSubmit = async (e) => { + e.preventDefault(); + + if (keymapId !== selectedKeymap?.id) { + await l10n.setKeymap(keymapId); + } + + onFinish(); + }; + + return ( + + + + + + + {_("Accept")} + + + + + ); +}; + +const KeymapButton = ({ children }) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + + const openPopup = () => setIsPopupOpen(true); + const closePopup = () => setIsPopupOpen(false); + + return ( + <> + + + + } + /> + + ); +}; + +const KeymapSection = () => { + const { selectedKeymap } = useL10n(); + return (
    -

    - TODO -

    + +

    {selectedKeymap?.name}

    + {_("Change keyboard")} + + } + else={ + <> +

    {_("Keyboard not selected yet")}

    + {_("Select keyboard")} + + } + />
    ); }; @@ -164,7 +253,7 @@ export default function L10nPage() { > - + ); } diff --git a/web/src/components/l10n/index.js b/web/src/components/l10n/index.js index d0ce623e4b..311f67bf5e 100644 --- a/web/src/components/l10n/index.js +++ b/web/src/components/l10n/index.js @@ -19,6 +19,7 @@ * find current contact information at www.suse.com. */ +export { default as KeymapSelector } from "./KeymapSelector"; export { default as L10nPage } from "./L10nPage"; export { default as LanguageSwitcher } from "./LanguageSwitcher"; export { default as LocaleSelector } from "./LocaleSelector"; diff --git a/web/src/context/l10n.jsx b/web/src/context/l10n.jsx index 798714d16e..05a2a768ed 100644 --- a/web/src/context/l10n.jsx +++ b/web/src/context/l10n.jsx @@ -30,19 +30,25 @@ function L10nProvider({ children }) { const { cancellablePromise } = useCancellablePromise(); const [locales, setLocales] = useState(); const [selectedLocales, setSelectedLocales] = useState(); + const [keymaps, setKeymaps] = useState(); + const [selectedKeymap, setSelectedKeymap] = useState(); useEffect(() => { const load = async () => { const locales = await cancellablePromise(client.l10n.locales()); const selectedLocales = await cancellablePromise(client.l10n.getLocales()); + const keymaps = await cancellablePromise(client.l10n.keymaps()); + const selectedKeymap = await cancellablePromise(client.l10n.getKeymap()); setLocales(locales); setSelectedLocales(selectedLocales); + setKeymaps(keymaps); + setSelectedKeymap(selectedKeymap); }; if (client) { load().catch(console.error); } - }, [client, setLocales, setSelectedLocales, cancellablePromise]); + }, [client, setLocales, setSelectedLocales, setKeymaps, setSelectedKeymap, cancellablePromise]); useEffect(() => { if (!client) return; @@ -50,7 +56,13 @@ function L10nProvider({ children }) { return client.l10n.onLocalesChange(setSelectedLocales); }, [client, setSelectedLocales]); - const value = { locales, selectedLocales }; + useEffect(() => { + if (!client) return; + + return client.l10n.onKeymapChange(setSelectedKeymap); + }, [client, setSelectedKeymap]); + + const value = { locales, selectedLocales, keymaps, selectedKeymap }; return {children}; } @@ -61,10 +73,17 @@ function useL10n() { throw new Error("useL10n must be used within a L10nProvider"); } - const { locales = [], selectedLocales: selectedIds = [] } = context; - const selectedLocales = selectedIds.map(id => locales.find(l => l.id === id)); + const { + locales = [], + selectedLocales: selectedLocalesId = [], + keymaps = [], + selectedKeymap: selectedKeymapId, + } = context; + + const selectedLocales = selectedLocalesId.map(id => locales.find(l => l.id === id)); + const selectedKeymap = keymaps.find(k => k.id === selectedKeymapId); - return { locales, selectedLocales }; + return { locales, selectedLocales, keymaps, selectedKeymap }; } export { L10nProvider, useL10n }; From e14d54c11afd586686242ff582294542824c89de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 23 Nov 2023 11:50:32 +0000 Subject: [PATCH 19/58] [web] Adapt styles --- web/src/assets/styles/blocks.scss | 56 +++++++++------------ web/src/components/l10n/LocaleSelector.jsx | 7 +-- web/src/components/storage/device-utils.jsx | 7 +-- 3 files changed, 32 insertions(+), 38 deletions(-) diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index 0439805a62..c39fee3234 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -276,11 +276,35 @@ span.notification-mark[data-variant="sidebar"] { gap: var(--spacer-small); + grid-template-columns: 1fr; + grid-template-areas: "content"; + text-align: start; + + > [data-type="details"] { + font-size: 80%; + } + &:hover { &:not([aria-selected]) { background: var(--color-gray-dark); } } + + &:is([data-type="locale"]) { + grid-template-columns: 1fr 2fr; + grid-template-areas: "name territory"; + } + + &:is([data-type="storage-device"]) { + grid-template-columns: 1fr 2fr 2fr; + grid-template-areas: "type-and-size drive-info drive-content"; + + [data-type="type-and-size"] { + align-self: center; + text-align: center; + justify-self: start; + } + } } [aria-selected] { @@ -294,38 +318,6 @@ span.notification-mark[data-variant="sidebar"] { fill: white; } } - - .device[role="option"] { - grid-template-columns: 1fr 2fr 2fr; - grid-template-areas: - "type-and-size drive-info drive-content" - ; - - > :first-child { - align-self: center; - text-align: center; - justify-self: start; - } - } - - .locale[role="option"] { - grid-template-columns: 1fr 2fr 1fr; - grid-template-areas: "id name territory"; - - > :first-child { - text-align: left; - } - - > :last-child { - text-align: right; - } - - &:not([aria-selected]) { - > :last-child { - color: var(--color-gray-darker); - } - } - } } // compact lists in popover diff --git a/web/src/components/l10n/LocaleSelector.jsx b/web/src/components/l10n/LocaleSelector.jsx index cb42ed299e..a96fb0d4d4 100644 --- a/web/src/components/l10n/LocaleSelector.jsx +++ b/web/src/components/l10n/LocaleSelector.jsx @@ -56,7 +56,7 @@ const LocaleItem = ({ locale }) => { <>
    {locale.name}
    {locale.territory}
    -
    {locale.id}
    +
    {locale.id}
    ); }; @@ -71,7 +71,7 @@ const LocaleItem = ({ locale }) => { * @param {(id: string) => void} [props.onChange] - Callback to be called when the selected locale * changes. */ -export default function LocaleSelector({ value, locales, onChange = noop }) { +export default function LocaleSelector({ value, locales = [], onChange = noop }) { return ( { locales.map(locale => ( @@ -79,7 +79,8 @@ export default function LocaleSelector({ value, locales, onChange = noop }) { key={locale.id} onClick={() => onChange(locale.id)} isSelected={locale.id === value} - className="cursor-pointer locale" + className="cursor-pointer" + {...{ "data-type": "locale" }} > diff --git a/web/src/components/storage/device-utils.jsx b/web/src/components/storage/device-utils.jsx index f6b9523bff..a1338d40b9 100644 --- a/web/src/components/storage/device-utils.jsx +++ b/web/src/components/storage/device-utils.jsx @@ -215,7 +215,7 @@ const DeviceItem = ({ device }) => { return ( <> - + @@ -233,7 +233,7 @@ const DeviceList = ({ devices }) => { return ( { devices.map(device => ( - + ))} @@ -274,7 +274,8 @@ const DeviceSelector = ({ devices, selected, isMultiple = false, onChange = noop key={device.sid} onClick={() => onOptionClick(device.name)} isSelected={isSelected(device.name)} - className="cursor-pointer device" + className="cursor-pointer" + {...{ "data-type": "storage-device" }} > From 4a7d1a47ef468674919358df4dea8f5fe8a2ba45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 23 Nov 2023 12:04:55 +0000 Subject: [PATCH 20/58] Extend locales D-Bus documentation --- rust/agama-dbus-server/src/locale.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index 83b064b660..4f62aba853 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -18,7 +18,7 @@ pub struct Locale { impl Locale { /// Gets the supported locales information. /// - /// Each element of the has these parts: + /// Each element of the list has these parts: /// /// * The locale code (e.g., "es_ES.UTF-8"). /// * A pair composed by the language and the territory names in english @@ -111,7 +111,7 @@ impl Locale { helpers::set_service_locale(locale); } - /// Gets list of locales available on system. + /// Returns a list of the locales available in the system. /// /// # Examples /// @@ -160,6 +160,12 @@ impl Locale { */ #[dbus_interface(name = "ListKeymaps")] + /// Returns a list of the supported keymaps. + /// + /// Each element of the list contains: + /// + /// * The keymap identifier (e.g., "es" or "es(ast)"). + /// * The name of the keyboard in language set by the UILocale property. fn list_keymaps(&self) -> Result, Error> { let keymaps = self .keymaps @@ -189,6 +195,13 @@ impl Locale { Ok(()) } + /// Returns a list of the supported timezones. + /// + /// Each element of the list contains: + /// + /// * The timezone identifier (e.g., "Europe/Berlin"). + /// * A list containing each part of the name in the language set by the + /// UILocale property. fn list_timezones(&self) -> Result)>, Error> { let language = self.ui_locale.split("_").next().unwrap_or(&self.ui_locale); let timezones = agama_locale_data::get_timezones(); From 716deba03023f3b49d957a02fbd49556dea71130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 23 Nov 2023 12:20:52 +0000 Subject: [PATCH 21/58] Clean-up unused method from locale D-Bus interface --- rust/agama-dbus-server/src/locale.rs | 65 +----------------------- rust/agama-lib/src/proxies.rs | 31 ++++++----- service/lib/agama/dbus/clients/locale.rb | 4 -- web/src/client/l10n.js | 16 ------ 4 files changed, 19 insertions(+), 97 deletions(-) diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index 4f62aba853..e1e4c257e2 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -2,7 +2,7 @@ use super::{helpers, keyboard::get_keymaps}; use crate::{error::Error, keyboard::Keymap}; use agama_locale_data::{KeymapId, LocaleCode}; use anyhow::Context; -use std::{fs::read_dir, process::Command}; +use std::process::Command; use zbus::{dbus_interface, Connection}; pub struct Locale { @@ -88,18 +88,6 @@ impl Locale { Ok(()) } - #[dbus_interface(property)] - fn supported_locales(&self) -> Vec { - self.supported_locales.to_owned() - } - - #[dbus_interface(property)] - fn set_supported_locales(&mut self, locales: Vec) -> Result<(), zbus::fdo::Error> { - self.supported_locales = locales; - // TODO: handle if current selected locale contain something that is no longer supported - Ok(()) - } - #[dbus_interface(property, name = "UILocale")] fn ui_locale(&self) -> &str { &self.ui_locale @@ -111,55 +99,6 @@ impl Locale { helpers::set_service_locale(locale); } - /// Returns a list of the locales available in the system. - /// - /// # Examples - /// - /// ``` - /// use agama_dbus_server::locale::Locale; - /// let locale = Locale::default(); - /// assert!(locale.list_ui_locales().unwrap().len() > 0); - /// ``` - #[dbus_interface(name = "ListUILocales")] - pub fn list_ui_locales(&self) -> Result, Error> { - // english is always available ui localization - let mut result = vec!["en".to_string()]; - const DIR: &str = "/usr/share/YaST2/locale/"; - let entries = read_dir(DIR); - if entries.is_err() { - // if dir is not there act like if it is empty - return Ok(result); - } - - for entry in entries.unwrap() { - let entry = entry.context("Failed to read entry in YaST2 locale dir")?; - let name = entry - .file_name() - .to_str() - .context("Non valid UTF entry found in YaST2 locale dir")? - .to_string(); - result.push(name) - } - - Ok(result) - } - - /* support only keymaps for console for now - fn list_x11_keyboards(&self) -> Result, Error> { - let keyboards = agama_locale_data::get_xkeyboards()?; - let ret = keyboards - .keyboard.iter() - .map(|k| (k.id.clone(), k.description.clone())) - .collect(); - Ok(ret) - } - - fn set_x11_keyboard(&mut self, keyboard: &str) { - self.keyboard_id = keyboard.to_string(); - } - */ - - #[dbus_interface(name = "ListKeymaps")] /// Returns a list of the supported keymaps. /// /// Each element of the list contains: @@ -286,7 +225,7 @@ impl Default for Locale { Self { locales: vec!["en_US.UTF-8".to_string()], timezone_id: "America/Los_Angeles".to_string(), - supported_locales: vec!["en_US.UTF-8".to_string(), "es_ES.UTF-8".to_string()], + supported_locales: vec!["en_US.UTF-8".to_string()], ui_locale: "en".to_string(), keymap: "us".parse().unwrap(), keymaps: vec![], diff --git a/rust/agama-lib/src/proxies.rs b/rust/agama-lib/src/proxies.rs index 97007935d2..e72cdf7401 100644 --- a/rust/agama-lib/src/proxies.rs +++ b/rust/agama-lib/src/proxies.rs @@ -75,40 +75,43 @@ trait Manager { ) -> zbus::Result>>; } -#[dbus_proxy(interface = "org.opensuse.Agama1.Locale", assume_defaults = true)] +#[dbus_proxy( + interface = "org.opensuse.Agama1.Locale", + default_service = "org.opensuse.Agama1", + default_path = "/org/opensuse/Agama1/Locale" +)] trait Locale { /// Commit method fn commit(&self) -> zbus::Result<()>; + /// ListKeymaps method + fn list_keymaps(&self) -> zbus::Result>; + /// ListLocales method fn list_locales(&self) -> zbus::Result>; /// ListTimezones method - fn list_timezones(&self, locale: &str) -> zbus::Result>; + fn list_timezones(&self) -> zbus::Result)>>; - /// ListVConsoleKeyboards method - #[dbus_proxy(name = "ListVConsoleKeyboards")] - fn list_vconsole_keyboards(&self) -> zbus::Result>; + /// Keymap property + #[dbus_proxy(property)] + fn keymap(&self) -> zbus::Result; + fn set_keymap(&self, value: &str) -> zbus::Result<()>; /// Locales property #[dbus_proxy(property)] fn locales(&self) -> zbus::Result>; fn set_locales(&self, value: &[&str]) -> zbus::Result<()>; - /// SupportedLocales property - #[dbus_proxy(property)] - fn supported_locales(&self) -> zbus::Result>; - fn set_supported_locales(&self, value: &[&str]) -> zbus::Result<()>; - /// Timezone property #[dbus_proxy(property)] fn timezone(&self) -> zbus::Result; fn set_timezone(&self, value: &str) -> zbus::Result<()>; - /// VConsoleKeyboard property - #[dbus_proxy(property, name = "VConsoleKeyboard")] - fn vconsole_keyboard(&self) -> zbus::Result; - fn set_vconsole_keyboard(&self, value: &str) -> zbus::Result<()>; + /// UILocale property + #[dbus_proxy(property, name = "UILocale")] + fn uilocale(&self) -> zbus::Result; + fn set_uilocale(&self, value: &str) -> zbus::Result<()>; } #[dbus_proxy( diff --git a/service/lib/agama/dbus/clients/locale.rb b/service/lib/agama/dbus/clients/locale.rb index 7a3a37b359..b79077b730 100644 --- a/service/lib/agama/dbus/clients/locale.rb +++ b/service/lib/agama/dbus/clients/locale.rb @@ -54,10 +54,6 @@ def ui_locale=(locale) dbus_object[INTERFACE_NAME]["UILocale"] = locale end - def available_ui_locales - dbus_object.ListUILocales - end - # Finishes the language installation def finish dbus_object.Commit diff --git a/web/src/client/l10n.js b/web/src/client/l10n.js index b04f613c57..b371c828e2 100644 --- a/web/src/client/l10n.js +++ b/web/src/client/l10n.js @@ -56,22 +56,6 @@ class L10nClient { this.client = new DBusClient(LOCALE_SERVICE, address); } - /** - * Available locales to translate the installer UI. - * - * Note that name and territory are localized to its own language: - * { id: "es", name: "Español", territory: "España" } - * - * @return {Promise>} - */ - async UILocales() { - const proxy = await this.client.proxy(LOCALE_IFACE); - const locales = await proxy.ListUILocales(); - - // TODO: D-Bus currently returns the id only - return locales.map(id => this.buildLocale([id, "", ""])); - } - /** * Selected locale to translate the installer UI. * From c2dc4eaaae7853569272d22b41a6cdaad4c493e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 23 Nov 2023 17:28:54 +0000 Subject: [PATCH 22/58] [web] Timezone selector --- web/src/assets/styles/blocks.scss | 26 ++++- web/src/client/l10n.js | 18 ++- web/src/components/l10n/L10nPage.jsx | 99 ++++++++++++++++- web/src/components/l10n/LocaleSelector.jsx | 2 +- web/src/components/l10n/TimezoneSelector.jsx | 110 +++++++++++++++++++ web/src/components/l10n/index.js | 1 + web/src/context/l10n.jsx | 23 +++- web/src/utils.js | 38 ++++++- 8 files changed, 303 insertions(+), 14 deletions(-) create mode 100644 web/src/components/l10n/TimezoneSelector.jsx diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index c39fee3234..af44357a1b 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -292,14 +292,36 @@ span.notification-mark[data-variant="sidebar"] { &:is([data-type="locale"]) { grid-template-columns: 1fr 2fr; - grid-template-areas: "name territory"; + grid-template-areas: + "language territory" + "details details"; + + > [data-type="details"] { + grid-area: details; + } + } + + &:is([data-type="timezone"]) { + grid-template-columns: 1fr 2fr 1fr; + grid-template-areas: + "part1 part2 time" + "details details ."; + + > [data-type="details"] { + grid-area: details; + } + + > [data-type="time"] { + grid-area: time; + text-align: end; + } } &:is([data-type="storage-device"]) { grid-template-columns: 1fr 2fr 2fr; grid-template-areas: "type-and-size drive-info drive-content"; - [data-type="type-and-size"] { + > [data-type="type-and-size"] { align-self: center; text-align: center; justify-self: start; diff --git a/web/src/client/l10n.js b/web/src/client/l10n.js index b371c828e2..62500d1192 100644 --- a/web/src/client/l10n.js +++ b/web/src/client/l10n.js @@ -86,8 +86,7 @@ class L10nClient { const proxy = await this.client.proxy(LOCALE_IFACE); const timezones = await proxy.ListTimezones(); - // TODO: D-Bus currently returns the timezone parts only - return timezones.map(parts => this.buildTimezone(["", parts])); + return timezones.map(this.buildTimezone); } /** @@ -181,6 +180,21 @@ class L10nClient { proxy.Keymap = id; } + /** + * Register a callback to run when Timezone D-Bus property changes. + * + * @param {(timezone: string) => void} handler - Function to call when Timezone changes. + * @return {import ("./dbus").RemoveFn} Function to disable the callback. + */ + onTimezoneChange(handler) { + return this.client.onObjectChanged(LOCALE_PATH, LOCALE_IFACE, changes => { + if ("Timezone" in changes) { + const id = changes.Timezone.v; + handler(id); + } + }); + } + /** * Register a callback to run when Locales D-Bus property changes. * diff --git a/web/src/components/l10n/L10nPage.jsx b/web/src/components/l10n/L10nPage.jsx index 7913e2fada..e1b2b2c910 100644 --- a/web/src/components/l10n/L10nPage.jsx +++ b/web/src/components/l10n/L10nPage.jsx @@ -25,16 +25,107 @@ import { Button, Form } from "@patternfly/react-core"; import { useInstallerClient } from "~/context/installer"; import { _ } from "~/i18n"; import { If, Page, Popup, Section } from "~/components/core"; -import { KeymapSelector, LocaleSelector } from "~/components/l10n"; +import { KeymapSelector, LocaleSelector, TimezoneSelector } from "~/components/l10n"; import { noop } from "~/utils"; import { useL10n } from "~/context/l10n"; +/** + * Popup for selecting a timezone. + * @component + * + * @param {object} props + * @param {function} props.onFinish - Callback to be called when the timezone is correctly selected. + * @param {function} props.onCancel - Callback to be called when the timezone selection is canceled. + */ +const TimezonePopup = ({ onFinish = noop, onCancel = noop }) => { + const { l10n } = useInstallerClient(); + const { timezones, selectedTimezone } = useL10n(); + const [timezoneId, setTimezoneId] = useState(selectedTimezone?.id); + + const sortedTimezones = timezones.sort((timezone1, timezone2) => { + const timezoneText = t => t.parts.join('').toLowerCase(); + return timezoneText(timezone1) > timezoneText(timezone2) ? 1 : -1; + }); + + const onSubmit = async (e) => { + e.preventDefault(); + + if (timezoneId !== selectedTimezone?.id) { + await l10n.setTimezone(timezoneId); + } + + onFinish(); + }; + + return ( + +
    + + + + + {_("Accept")} + + + +
    + ); +}; + +const TimezoneButton = ({ children }) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + + const openPopup = () => setIsPopupOpen(true); + const closePopup = () => setIsPopupOpen(false); + + return ( + <> + + + + } + /> + + ); +}; + const TimezoneSection = () => { + const { selectedTimezone } = useL10n(); + return (
    -

    - TODO -

    + +

    {(selectedTimezone?.parts || []).join(' - ')}

    + {_("Change time zone")} + + } + else={ + <> +

    {_("Time zone not selected yet")}

    + {_("Select time zone")} + + } + />
    ); }; diff --git a/web/src/components/l10n/LocaleSelector.jsx b/web/src/components/l10n/LocaleSelector.jsx index a96fb0d4d4..cbd2e01917 100644 --- a/web/src/components/l10n/LocaleSelector.jsx +++ b/web/src/components/l10n/LocaleSelector.jsx @@ -45,7 +45,7 @@ const ListBoxItem = ({ isSelected, children, onClick, ...props }) => { }; /** - * Content for a device item + * Content for a locale item * @component * * @param {Object} props diff --git a/web/src/components/l10n/TimezoneSelector.jsx b/web/src/components/l10n/TimezoneSelector.jsx new file mode 100644 index 0000000000..13de931e31 --- /dev/null +++ b/web/src/components/l10n/TimezoneSelector.jsx @@ -0,0 +1,110 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; + +import { _ } from "~/i18n"; +import { noop, timezoneTime, timezoneUTCOffset } from "~/utils"; + +/** + * @typedef {import ("~/clients/l10n").Timezone} Timezone + */ + +const ListBox = ({ children, ...props }) =>
      {children}
    ; + +const ListBoxItem = ({ isSelected, children, onClick, ...props }) => { + if (isSelected) props['aria-selected'] = true; + + return ( +
  • + {children} +
  • + ); +}; + +const timezoneDetails = (timezone) => { + const offset = timezoneUTCOffset(timezone.id); + + if (offset === undefined) return timezone.id; + + let utc = "UTC"; + if (offset > 0) utc += `+${offset}`; + if (offset < 0) utc += `${offset}`; + + return `${timezone.id} ${utc}`; +}; + +/** + * Content for a timezone item + * @component + * + * @param {Object} props + * @param {Timezone} props.timezone + * @param {Date} props.date - Date to show a time. + */ +const TimezoneItem = ({ timezone, date }) => { + const [part1, ...restParts] = timezone.parts; + const time = timezoneTime(timezone.id, { date }) || ""; + const details = timezoneDetails(timezone); + + return ( + <> +
    {part1}
    +
    {restParts.join('-')}
    +
    {time || ""}
    +
    {details}
    + + ); +}; + +/** + * Component for selecting a timezone. + * @component + * + * @param {Object} props + * @param {string} [props.value] - Id of the currently selected timezone. + * @param {Locale[]} [props.timezones] - Timezones for selection. + * @param {(id: string) => void} [props.onChange] - Callback to be called when the selected timezone + * changes. + */ +export default function TimezoneSelector({ value, timezones = [], onChange = noop }) { + const date = new Date(); + + return ( + + { timezones.map(timezone => ( + onChange(timezone.id)} + isSelected={timezone.id === value} + className="cursor-pointer" + {...{ "data-type": "timezone" }} + > + + + ))} + + ); +} diff --git a/web/src/components/l10n/index.js b/web/src/components/l10n/index.js index 311f67bf5e..a809d1485f 100644 --- a/web/src/components/l10n/index.js +++ b/web/src/components/l10n/index.js @@ -23,3 +23,4 @@ export { default as KeymapSelector } from "./KeymapSelector"; export { default as L10nPage } from "./L10nPage"; export { default as LanguageSwitcher } from "./LanguageSwitcher"; export { default as LocaleSelector } from "./LocaleSelector"; +export { default as TimezoneSelector } from "./TimezoneSelector"; diff --git a/web/src/context/l10n.jsx b/web/src/context/l10n.jsx index 05a2a768ed..ac0a6c2f06 100644 --- a/web/src/context/l10n.jsx +++ b/web/src/context/l10n.jsx @@ -28,6 +28,8 @@ const L10nContext = React.createContext({}); function L10nProvider({ children }) { const client = useInstallerClient(); const { cancellablePromise } = useCancellablePromise(); + const [timezones, setTimezones] = useState(); + const [selectedTimezone, setSelectedTimezone] = useState(); const [locales, setLocales] = useState(); const [selectedLocales, setSelectedLocales] = useState(); const [keymaps, setKeymaps] = useState(); @@ -35,10 +37,14 @@ function L10nProvider({ children }) { useEffect(() => { const load = async () => { + const timezones = await cancellablePromise(client.l10n.timezones()); + const selectedTimezone = await cancellablePromise(client.l10n.getTimezone()); const locales = await cancellablePromise(client.l10n.locales()); const selectedLocales = await cancellablePromise(client.l10n.getLocales()); const keymaps = await cancellablePromise(client.l10n.keymaps()); const selectedKeymap = await cancellablePromise(client.l10n.getKeymap()); + setTimezones(timezones); + setSelectedTimezone(selectedTimezone); setLocales(locales); setSelectedLocales(selectedLocales); setKeymaps(keymaps); @@ -48,7 +54,13 @@ function L10nProvider({ children }) { if (client) { load().catch(console.error); } - }, [client, setLocales, setSelectedLocales, setKeymaps, setSelectedKeymap, cancellablePromise]); + }, [cancellablePromise, client, setKeymaps, setLocales, setSelectedKeymap, setSelectedLocales, setSelectedTimezone, setTimezones]); + + useEffect(() => { + if (!client) return; + + return client.l10n.onTimezoneChange(setSelectedTimezone); + }, [client, setSelectedTimezone]); useEffect(() => { if (!client) return; @@ -62,7 +74,7 @@ function L10nProvider({ children }) { return client.l10n.onKeymapChange(setSelectedKeymap); }, [client, setSelectedKeymap]); - const value = { locales, selectedLocales, keymaps, selectedKeymap }; + const value = { timezones, selectedTimezone, locales, selectedLocales, keymaps, selectedKeymap }; return {children}; } @@ -74,16 +86,19 @@ function useL10n() { } const { + timezones = [], + selectedTimezone: selectedTimezoneId, locales = [], selectedLocales: selectedLocalesId = [], keymaps = [], - selectedKeymap: selectedKeymapId, + selectedKeymap: selectedKeymapId } = context; + const selectedTimezone = timezones.find(t => t.id === selectedTimezoneId); const selectedLocales = selectedLocalesId.map(id => locales.find(l => l.id === id)); const selectedKeymap = keymaps.find(k => k.id === selectedKeymapId); - return { locales, selectedLocales, keymaps, selectedKeymap }; + return { timezones, selectedTimezone, locales, selectedLocales, keymaps, selectedKeymap }; } export { L10nProvider, useL10n }; diff --git a/web/src/utils.js b/web/src/utils.js index 3cd85fb228..de56f86a8d 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -208,6 +208,40 @@ const setLocationSearch = (query) => { window.location.search = query; }; +const timezoneTime = (timezone, { date = new Date() }) => { + try { + const formater = new Intl.DateTimeFormat( + "en-US", + { timeZone: timezone, timeStyle: "short", hour12: false } + ); + + return formater.format(date); + } catch (e) { + if (e instanceof RangeError) return undefined; + + throw e; + } +}; + +const timezoneUTCOffset = (timezone) => { + try { + const date = new Date(); + const dateLocaleString = date.toLocaleString( + "en-US", + { timeZone: timezone, timeZoneName: "short" } + ); + const [timezoneName] = dateLocaleString.split(' ').slice(-1); + const dateString = date.toString(); + const offset = Date.parse(`${dateString} UTC`) - Date.parse(`${dateString} ${timezoneName}`); + + return offset / 3600000; + } catch (e) { + if (e instanceof RangeError) return undefined; + + throw e; + } +}; + export { noop, partition, @@ -217,5 +251,7 @@ export { hex, toValidationError, locationReload, - setLocationSearch + setLocationSearch, + timezoneTime, + timezoneUTCOffset }; From 27baa5b0122450a262510a03ac1b79858008760a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 24 Nov 2023 11:00:41 +0000 Subject: [PATCH 23/58] [web] Several improvements --- web/cspell.json | 2 ++ web/src/assets/styles/blocks.scss | 2 +- web/src/client/l10n.js | 6 +++++- web/src/components/l10n/KeymapSelector.jsx | 4 ++-- web/src/components/l10n/LocaleSelector.jsx | 4 ++-- web/src/components/l10n/TimezoneSelector.jsx | 8 ++++---- web/src/utils.js | 4 ++-- 7 files changed, 18 insertions(+), 12 deletions(-) diff --git a/web/cspell.json b/web/cspell.json index fa98f5ff09..428bf4ef41 100644 --- a/web/cspell.json +++ b/web/cspell.json @@ -43,6 +43,8 @@ "ipaddr", "iscsi", "jdoe", + "keymap", + "keymaps", "libyui", "lldp", "localdomain", diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index af44357a1b..5f45d6147e 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -304,7 +304,7 @@ span.notification-mark[data-variant="sidebar"] { &:is([data-type="timezone"]) { grid-template-columns: 1fr 2fr 1fr; grid-template-areas: - "part1 part2 time" + "part1 rest-parts time" "details details ."; > [data-type="details"] { diff --git a/web/src/client/l10n.js b/web/src/client/l10n.js index 62500d1192..cf9154a6dd 100644 --- a/web/src/client/l10n.js +++ b/web/src/client/l10n.js @@ -21,6 +21,7 @@ // @ts-check import DBusClient from "./dbus"; +import { timezoneUTCOffset } from "~/utils"; const LOCALE_SERVICE = "org.opensuse.Agama1"; const LOCALE_IFACE = "org.opensuse.Agama1.Locale"; @@ -30,6 +31,7 @@ const LOCALE_PATH = "/org/opensuse/Agama1/Locale"; * @typedef {object} Timezone * @property {string} id - Timezone id (e.g., "Atlantic/Canary"). * @property {Array} parts - Name of the timezone parts (e.g., ["Atlantic", "Canary"]). + * @property {number} utcOffset - UTC offset. */ /** @@ -232,7 +234,9 @@ class L10nClient { * @returns {Timezone} */ buildTimezone([id, parts]) { - return ({ id, parts }); + const utcOffset = timezoneUTCOffset(id); + + return ({ id, parts, utcOffset }); } /** diff --git a/web/src/components/l10n/KeymapSelector.jsx b/web/src/components/l10n/KeymapSelector.jsx index d7956c93b0..75b9363d77 100644 --- a/web/src/components/l10n/KeymapSelector.jsx +++ b/web/src/components/l10n/KeymapSelector.jsx @@ -73,9 +73,9 @@ const KeymapItem = ({ keymap }) => { export default function KeymapSelector({ value, keymaps = [], onChange = noop }) { return ( - { keymaps.map(keymap => ( + { keymaps.map((keymap, index) => ( onChange(keymap.id)} isSelected={keymap.id === value} className="cursor-pointer" diff --git a/web/src/components/l10n/LocaleSelector.jsx b/web/src/components/l10n/LocaleSelector.jsx index cbd2e01917..75af11b393 100644 --- a/web/src/components/l10n/LocaleSelector.jsx +++ b/web/src/components/l10n/LocaleSelector.jsx @@ -74,9 +74,9 @@ const LocaleItem = ({ locale }) => { export default function LocaleSelector({ value, locales = [], onChange = noop }) { return ( - { locales.map(locale => ( + { locales.map((locale, index) => ( onChange(locale.id)} isSelected={locale.id === value} className="cursor-pointer" diff --git a/web/src/components/l10n/TimezoneSelector.jsx b/web/src/components/l10n/TimezoneSelector.jsx index 13de931e31..0c4ca96470 100644 --- a/web/src/components/l10n/TimezoneSelector.jsx +++ b/web/src/components/l10n/TimezoneSelector.jsx @@ -22,7 +22,7 @@ import React from "react"; import { _ } from "~/i18n"; -import { noop, timezoneTime, timezoneUTCOffset } from "~/utils"; +import { noop, timezoneTime } from "~/utils"; /** * @typedef {import ("~/clients/l10n").Timezone} Timezone @@ -45,7 +45,7 @@ const ListBoxItem = ({ isSelected, children, onClick, ...props }) => { }; const timezoneDetails = (timezone) => { - const offset = timezoneUTCOffset(timezone.id); + const offset = timezone.utcOffset; if (offset === undefined) return timezone.id; @@ -94,9 +94,9 @@ export default function TimezoneSelector({ value, timezones = [], onChange = noo return ( - { timezones.map(timezone => ( + { timezones.map((timezone, index) => ( onChange(timezone.id)} isSelected={timezone.id === value} className="cursor-pointer" diff --git a/web/src/utils.js b/web/src/utils.js index de56f86a8d..30d4277d8e 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -210,12 +210,12 @@ const setLocationSearch = (query) => { const timezoneTime = (timezone, { date = new Date() }) => { try { - const formater = new Intl.DateTimeFormat( + const formatter = new Intl.DateTimeFormat( "en-US", { timeZone: timezone, timeStyle: "short", hour12: false } ); - return formater.format(date); + return formatter.format(date); } catch (e) { if (e instanceof RangeError) return undefined; From 75b1d5aa8e1df5e1e1ffbf8b7b811ef506eb86fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 23 Nov 2023 15:41:30 +0000 Subject: [PATCH 24/58] Remove the supported_locales method * Most probably, we will need to bring it back in the future. --- service/lib/agama/dbus/clients/locale.rb | 7 ------- service/test/agama/dbus/clients/locale_test.rb | 10 ---------- 2 files changed, 17 deletions(-) diff --git a/service/lib/agama/dbus/clients/locale.rb b/service/lib/agama/dbus/clients/locale.rb index b79077b730..2597ba117d 100644 --- a/service/lib/agama/dbus/clients/locale.rb +++ b/service/lib/agama/dbus/clients/locale.rb @@ -39,13 +39,6 @@ def service_name @service_name ||= "org.opensuse.Agama1" end - # Sets the supported locales. It can differs per product. - # - # @param locales [Array] - def supported_locales=(locales) - dbus_object.supported_locales = locales - end - def ui_locale dbus_object[INTERFACE_NAME]["UILocale"] end diff --git a/service/test/agama/dbus/clients/locale_test.rb b/service/test/agama/dbus/clients/locale_test.rb index 431c08515f..4af5442590 100644 --- a/service/test/agama/dbus/clients/locale_test.rb +++ b/service/test/agama/dbus/clients/locale_test.rb @@ -41,16 +41,6 @@ subject { described_class.new } - describe "#supported_locales=" do - # Using partial double because methods are dynamically added to the proxy object - let(:dbus_object) { double(DBus::ProxyObject) } - - it "calls the D-Bus object" do - expect(dbus_object).to receive(:supported_locales=).with(["no", "se"]) - subject.supported_locales = ["no", "se"] - end - end - describe "#finish" do let(:dbus_object) { double(DBus::ProxyObject) } From 3b44b670072a4e509939cccfa38546f5024e09d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 24 Nov 2023 12:11:53 +0000 Subject: [PATCH 25/58] [service] Make the Locale client singleton It fixes a potential problem when calling #on_language_change on different objects. --- service/lib/agama/dbus/clients/base.rb | 16 ++++++++++++++++ service/lib/agama/dbus/clients/locale.rb | 2 ++ service/lib/agama/dbus/manager_service.rb | 2 +- service/lib/agama/dbus/software/manager.rb | 3 +-- service/lib/agama/dbus/software_service.rb | 3 +-- service/lib/agama/dbus/storage_service.rb | 3 +-- service/lib/agama/manager.rb | 2 +- service/test/agama/dbus/clients/locale_test.rb | 2 +- service/test/agama/dbus/manager_service_test.rb | 2 +- service/test/agama/dbus/software/manager_test.rb | 2 +- service/test/agama/manager_test.rb | 2 +- 11 files changed, 27 insertions(+), 12 deletions(-) diff --git a/service/lib/agama/dbus/clients/base.rb b/service/lib/agama/dbus/clients/base.rb index 9aa6b633e2..bb26770c42 100644 --- a/service/lib/agama/dbus/clients/base.rb +++ b/service/lib/agama/dbus/clients/base.rb @@ -27,6 +27,22 @@ module Agama module DBus module Clients # Base class for D-Bus clients + # + # The clients should be singleton because the #on_properties_change method + # will not work properly with several instances. Given a + # ::DBus::BusConnection object, ruby-dbus does not allow to register more + # than one callback for the same object. + # + # It causes the last instance to overwrite the callbacks from previous ones. + # + # @example Creating a new client + # require "singleton" + # + # class Locale < Base + # include Singleton + # + # # client methods + # end class Base # @!method service_name # Name of the D-Bus service diff --git a/service/lib/agama/dbus/clients/locale.rb b/service/lib/agama/dbus/clients/locale.rb index 2597ba117d..1cc0cd797a 100644 --- a/service/lib/agama/dbus/clients/locale.rb +++ b/service/lib/agama/dbus/clients/locale.rb @@ -20,12 +20,14 @@ # find current contact information at www.suse.com. require "agama/dbus/clients/base" +require "singleton" module Agama module DBus module Clients # D-Bus client for locale configuration class Locale < Base + include Singleton INTERFACE_NAME = "org.opensuse.Agama1.Locale" def initialize diff --git a/service/lib/agama/dbus/manager_service.rb b/service/lib/agama/dbus/manager_service.rb index 4c73938a0f..6351ef4031 100644 --- a/service/lib/agama/dbus/manager_service.rb +++ b/service/lib/agama/dbus/manager_service.rb @@ -73,7 +73,7 @@ def start setup_cockpit export # We need locale for data from users - locale_client = Clients::Locale.new + locale_client = Clients::Locale.instance # TODO: test if we need to pass block with additional actions @ui_locale = UILocale.new(locale_client) manager.on_progress_change { dispatch } # make single thread more responsive diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index aad196e3cb..22392a793f 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -132,8 +132,7 @@ def finish # Registers callback to be called def register_callbacks - lang_client = Agama::DBus::Clients::Locale.new - lang_client.on_language_selected do |language_ids| + Agama::DBus::Clients::Locale.instance.on_language_selected do |language_ids| backend.languages = language_ids end diff --git a/service/lib/agama/dbus/software_service.rb b/service/lib/agama/dbus/software_service.rb index 3f357bb000..245f2f6a97 100644 --- a/service/lib/agama/dbus/software_service.rb +++ b/service/lib/agama/dbus/software_service.rb @@ -58,8 +58,7 @@ def start # for some reason the the "export" method must be called before # registering the language change callback to work properly export - locale_client = Clients::Locale.new - @ui_locale = UILocale.new(locale_client) do |locale| + @ui_locale = UILocale.new(Clients::Locale.instance) do |locale| # set the locale in the Language module, when changing the repository # (product) it calls Pkg.SetTextLocale(Language.language) internally Yast::Language.Set(locale) diff --git a/service/lib/agama/dbus/storage_service.rb b/service/lib/agama/dbus/storage_service.rb index 773e37a3e9..37da268335 100644 --- a/service/lib/agama/dbus/storage_service.rb +++ b/service/lib/agama/dbus/storage_service.rb @@ -53,9 +53,8 @@ def bus # Starts storage service. It does more then just #export method. def start export - locale_client = Clients::Locale.new # TODO: test if we need to pass block with additional actions - @ui_locale = UILocale.new(locale_client) + @ui_locale = UILocale.new(Clients::Locale.instance) end # Exports the storage proposal object through the D-Bus service diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index b11b5960cb..ae9f0d4401 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -152,7 +152,7 @@ def proxy # # @return [DBus::Clients::Locale] def language - @language ||= DBus::Clients::Locale.new + DBus::Clients::Locale.instance end # Users client diff --git a/service/test/agama/dbus/clients/locale_test.rb b/service/test/agama/dbus/clients/locale_test.rb index 4af5442590..c454bc0f68 100644 --- a/service/test/agama/dbus/clients/locale_test.rb +++ b/service/test/agama/dbus/clients/locale_test.rb @@ -39,7 +39,7 @@ let(:dbus_object) { instance_double(DBus::ProxyObject) } let(:lang_iface) { instance_double(DBus::ProxyObjectInterface) } - subject { described_class.new } + subject { described_class.instance } describe "#finish" do let(:dbus_object) { double(DBus::ProxyObject) } diff --git a/service/test/agama/dbus/manager_service_test.rb b/service/test/agama/dbus/manager_service_test.rb index 8de02632b6..6149a198cf 100644 --- a/service/test/agama/dbus/manager_service_test.rb +++ b/service/test/agama/dbus/manager_service_test.rb @@ -48,7 +48,7 @@ .and_return(object_server) allow(Agama::Manager).to receive(:new).with(config, logger).and_return(manager) allow(Agama::CockpitManager).to receive(:new).and_return(cockpit) - allow(Agama::DBus::Clients::Locale).to receive(:new).and_return(locale_client) + allow(Agama::DBus::Clients::Locale).to receive(:instance).and_return(locale_client) allow(Agama::DBus::Manager).to receive(:new).with(manager, logger).and_return(manager_obj) allow(Agama::DBus::Users).to receive(:new).and_return(users_obj) end diff --git a/service/test/agama/dbus/software/manager_test.rb b/service/test/agama/dbus/software/manager_test.rb index 925218b9d5..ead7fcdc49 100644 --- a/service/test/agama/dbus/software/manager_test.rb +++ b/service/test/agama/dbus/software/manager_test.rb @@ -52,7 +52,7 @@ let(:issues_interface) { Agama::DBus::Interfaces::Issues::ISSUES_INTERFACE } before do - allow(Agama::DBus::Clients::Locale).to receive(:new).and_return(locale_client) + allow(Agama::DBus::Clients::Locale).to receive(:instance).and_return(locale_client) allow(Agama::DBus::Clients::Network).to receive(:new).and_return(network_client) allow(backend).to receive(:probe) allow(backend).to receive(:propose) diff --git a/service/test/agama/manager_test.rb b/service/test/agama/manager_test.rb index 9e465df938..38a41d2ed3 100644 --- a/service/test/agama/manager_test.rb +++ b/service/test/agama/manager_test.rb @@ -65,7 +65,7 @@ before do allow(Agama::Network).to receive(:new).and_return(network) allow(Agama::ProxySetup).to receive(:instance).and_return(proxy) - allow(Agama::DBus::Clients::Locale).to receive(:new).and_return(locale) + allow(Agama::DBus::Clients::Locale).to receive(:instance).and_return(locale) allow(Agama::DBus::Clients::Software).to receive(:new).and_return(software) allow(Agama::DBus::Clients::Storage).to receive(:new).and_return(storage) allow(Agama::Users).to receive(:new).and_return(users) From f0f7574635835ca3d7c8666ec11e5ca84f70dac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 24 Nov 2023 12:12:46 +0000 Subject: [PATCH 26/58] [service] Evaluate the software proposal when changing the language --- service/lib/agama/dbus/software/manager.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 22392a793f..a22729c3eb 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -134,6 +134,7 @@ def finish def register_callbacks Agama::DBus::Clients::Locale.instance.on_language_selected do |language_ids| backend.languages = language_ids + probe end nm_client = Agama::DBus::Clients::Network.new From 46f198caf4944b7112a589558899dd0b7e2494e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 24 Nov 2023 12:35:11 +0000 Subject: [PATCH 27/58] [service] Drop the encoding part from software proposal locales --- service/lib/agama/software/proposal.rb | 10 +++++++++- service/test/agama/software/proposal_test.rb | 7 +++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/service/lib/agama/software/proposal.rb b/service/lib/agama/software/proposal.rb index 383e1509d5..9f3dac5c14 100644 --- a/service/lib/agama/software/proposal.rb +++ b/service/lib/agama/software/proposal.rb @@ -56,7 +56,7 @@ class Proposal attr_accessor :base_product # @return [Array] List of languages to install - attr_accessor :languages + attr_reader :languages # Constructor # @@ -114,6 +114,13 @@ def valid? !(proposal.nil? || errors?) end + # Sets the languages to install + # + # @param [Array] value Languages in xx_XX format (e.g., "en_US"). + def languages=(value) + @languages = value.map { |l| l.split(".").first }.compact + end + private # @return [Logger] @@ -129,6 +136,7 @@ def initialize_target Yast::Pkg.TargetFinish # ensure that previous target is closed Yast::Pkg.TargetInitialize(Yast::Installation.destdir) Yast::Pkg.TargetLoad + logger.info "Registering locales #{languages}" Yast::Pkg.SetAdditionalLocales(languages) Yast::Pkg.SetSolverFlags("ignoreAlreadyRecommended" => false, "onlyRequires" => true) end diff --git a/service/test/agama/software/proposal_test.rb b/service/test/agama/software/proposal_test.rb index 8dfdf1cb94..396bc2cd37 100644 --- a/service/test/agama/software/proposal_test.rb +++ b/service/test/agama/software/proposal_test.rb @@ -162,4 +162,11 @@ end end end + + describe "#languages" do + it "sets the languages to install removing the encoding" do + subject.languages = ["es_ES.UTF-8", "en_US"] + expect(subject.languages).to eq(["es_ES", "en_US"]) + end + end end From 428f3cdcc176246675ead0472bdc1597dce7ff47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 24 Nov 2023 15:51:04 +0000 Subject: [PATCH 28/58] WIP --- web/src/assets/styles/blocks.scss | 18 +++++ web/src/assets/styles/utilities.scss | 4 + web/src/assets/styles/variables.scss | 1 + web/src/components/l10n/L10nPage.jsx | 1 + web/src/components/l10n/TimezoneSelector.jsx | 83 ++++++++++++++++---- 5 files changed, 90 insertions(+), 17 deletions(-) diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index 5f45d6147e..f3401e4db3 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -312,6 +312,7 @@ span.notification-mark[data-variant="sidebar"] { } > [data-type="time"] { + color: var(--color-gray-dimmed); grid-area: time; text-align: end; } @@ -342,6 +343,23 @@ span.notification-mark[data-variant="sidebar"] { } } +[role="dialog"] { + [role="search"] { + position: sticky; + top: calc(-1 * var(--pf-v5-c-modal-box__body--PaddingTop)); + margin-top: calc(-1 * var(--pf-v5-c-modal-box__body--PaddingTop)); + padding-top: var(--pf-v5-c-modal-box__body--PaddingTop); + background-color: var(--pf-v5-c-modal-box--BackgroundColor); + + > input { + width: 100%; + padding: var(--spacer-small); + border: 1px solid var(--color-primary); + border-radius: 5px; + } + } +} + // compact lists in popover .pf-v5-c-popover li + li { margin: 0; diff --git a/web/src/assets/styles/utilities.scss b/web/src/assets/styles/utilities.scss index 1f84016877..03b77465c1 100644 --- a/web/src/assets/styles/utilities.scss +++ b/web/src/assets/styles/utilities.scss @@ -164,3 +164,7 @@ .cursor-pointer { cursor: pointer; } + +.height-75 { + height: 75dvh; +} diff --git a/web/src/assets/styles/variables.scss b/web/src/assets/styles/variables.scss index 670e87510c..92afe4211b 100644 --- a/web/src/assets/styles/variables.scss +++ b/web/src/assets/styles/variables.scss @@ -35,6 +35,7 @@ --color-gray: #f2f2f2; --color-gray-dark: #efefef; // Fog --color-gray-darker: #999; + --color-gray-dimmed: #888; --color-link: #0c322c; --color-link-hover: #30ba78; diff --git a/web/src/components/l10n/L10nPage.jsx b/web/src/components/l10n/L10nPage.jsx index e1b2b2c910..6087e41916 100644 --- a/web/src/components/l10n/L10nPage.jsx +++ b/web/src/components/l10n/L10nPage.jsx @@ -62,6 +62,7 @@ const TimezonePopup = ({ onFinish = noop, onCancel = noop }) => { isOpen title={_("Select time zone")} description={_("The product will be installed using the selected time zone.")} + className="height-75" >
    diff --git a/web/src/components/l10n/TimezoneSelector.jsx b/web/src/components/l10n/TimezoneSelector.jsx index 0c4ca96470..21da52ae35 100644 --- a/web/src/components/l10n/TimezoneSelector.jsx +++ b/web/src/components/l10n/TimezoneSelector.jsx @@ -19,7 +19,7 @@ * find current contact information at www.suse.com. */ -import React from "react"; +import React, { useEffect, useRef, useState } from "react"; import { _ } from "~/i18n"; import { noop, timezoneTime } from "~/utils"; @@ -67,18 +67,42 @@ const timezoneDetails = (timezone) => { const TimezoneItem = ({ timezone, date }) => { const [part1, ...restParts] = timezone.parts; const time = timezoneTime(timezone.id, { date }) || ""; - const details = timezoneDetails(timezone); return ( <>
    {part1}
    {restParts.join('-')}
    -
    {time || ""}
    -
    {details}
    +
    {time || ""}
    +
    {timezone.details}
    ); }; +const useDebounce = (callback, delay) => { + const timeoutRef = useRef(null); + + useEffect(() => { + // Cleanup the previous timeout on re-render + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const debouncedCallback = (...args) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + callback(...args); + }, delay); + }; + + return debouncedCallback; +}; + /** * Component for selecting a timezone. * @component @@ -90,21 +114,46 @@ const TimezoneItem = ({ timezone, date }) => { * changes. */ export default function TimezoneSelector({ value, timezones = [], onChange = noop }) { + const displayTimezones = timezones.map(t => ({ ...t, details: timezoneDetails(t) })); + const [filteredTimezones, setFilteredTimezones] = useState(displayTimezones); + + const search = useDebounce((term) => { + const filtered = displayTimezones.filter(timezone => { + const values = Object.values(timezone) + .join('') + .toLowerCase(); + return values.includes(term); + }); + + console.log("search: ", term); + setFilteredTimezones(filtered); + }, 500); + + const onSearchChange = (e) => { + const value = e.target.value; + search(value); + }; + const date = new Date(); return ( - - { timezones.map((timezone, index) => ( - onChange(timezone.id)} - isSelected={timezone.id === value} - className="cursor-pointer" - {...{ "data-type": "timezone" }} - > - - - ))} - + <> +
    + +
    + + { filteredTimezones.map((timezone, index) => ( + onChange(timezone.id)} + isSelected={timezone.id === value} + className="cursor-pointer" + data-type="timezone" + > + + + ))} + + ); } From 2d584165a384324a49e026ba59e48522b7e4d3ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 24 Nov 2023 16:32:28 +0000 Subject: [PATCH 29/58] [web] Add search --- web/src/components/core/ListSearch.jsx | 48 +++++++++++++++++++ web/src/components/core/index.js | 1 + web/src/components/l10n/KeymapSelector.jsx | 32 ++++++++----- web/src/components/l10n/L10nPage.jsx | 2 + web/src/components/l10n/LocaleSelector.jsx | 34 +++++++------ web/src/components/l10n/TimezoneSelector.jsx | 50 ++------------------ web/src/utils.js | 31 ++++++++++++ 7 files changed, 124 insertions(+), 74 deletions(-) create mode 100644 web/src/components/core/ListSearch.jsx diff --git a/web/src/components/core/ListSearch.jsx b/web/src/components/core/ListSearch.jsx new file mode 100644 index 0000000000..c4c7acd587 --- /dev/null +++ b/web/src/components/core/ListSearch.jsx @@ -0,0 +1,48 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; + +import { _ } from "~/i18n"; +import { noop, useDebounce } from "~/utils"; + +const search = (elements, term) => { + const match = (element) => { + return Object.values(element) + .join('') + .toLowerCase() + .includes(term); + }; + + return elements.filter(match); +}; + +export default function ListSearch({ elements = [], onChange: onChangeProp = noop }) { + const searchHandler = useDebounce(term => onChangeProp(search(elements, term)), 500); + + const onChange = (e) => searchHandler(e.target.value); + + return ( +
    + +
    + ); +} diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js index 73593f96ed..5a3d808cf0 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -39,6 +39,7 @@ export { default as InstallButton } from "./InstallButton"; export { default as IssuesLink } from "./IssuesLink"; export { default as IssuesPage } from "./IssuesPage"; export { default as SectionSkeleton } from "./SectionSkeleton"; +export { default as ListSearch } from "./ListSearch"; export { default as LogsButton } from "./LogsButton"; export { default as FileViewer } from "./FileViewer"; export { default as RowActions } from "./RowActions"; diff --git a/web/src/components/l10n/KeymapSelector.jsx b/web/src/components/l10n/KeymapSelector.jsx index 75b9363d77..43f84cd9a6 100644 --- a/web/src/components/l10n/KeymapSelector.jsx +++ b/web/src/components/l10n/KeymapSelector.jsx @@ -19,9 +19,10 @@ * find current contact information at www.suse.com. */ -import React from "react"; +import React, { useState } from "react"; import { _ } from "~/i18n"; +import { ListSearch } from "~/components/core"; import { noop } from "~/utils"; /** @@ -71,18 +72,23 @@ const KeymapItem = ({ keymap }) => { * changes. */ export default function KeymapSelector({ value, keymaps = [], onChange = noop }) { + const [filteredKeymaps, setFilteredKeymaps] = useState(keymaps); + return ( - - { keymaps.map((keymap, index) => ( - onChange(keymap.id)} - isSelected={keymap.id === value} - className="cursor-pointer" - > - - - ))} - + <> + + + { filteredKeymaps.map((keymap, index) => ( + onChange(keymap.id)} + isSelected={keymap.id === value} + className="cursor-pointer" + > + + + ))} + + ); } diff --git a/web/src/components/l10n/L10nPage.jsx b/web/src/components/l10n/L10nPage.jsx index 6087e41916..f57d73bb24 100644 --- a/web/src/components/l10n/L10nPage.jsx +++ b/web/src/components/l10n/L10nPage.jsx @@ -166,6 +166,7 @@ const LocalePopup = ({ onFinish = noop, onCancel = noop }) => { isOpen title={_("Select language")} description={_("The product will be installed using the selected language.")} + className="height-75" > @@ -266,6 +267,7 @@ const KeymapPopup = ({ onFinish = noop, onCancel = noop }) => { isOpen title={_("Select keyboard")} description={_("The product will be installed using the selected keyboard.")} + className="height-75" > diff --git a/web/src/components/l10n/LocaleSelector.jsx b/web/src/components/l10n/LocaleSelector.jsx index 75af11b393..d402f83259 100644 --- a/web/src/components/l10n/LocaleSelector.jsx +++ b/web/src/components/l10n/LocaleSelector.jsx @@ -19,9 +19,10 @@ * find current contact information at www.suse.com. */ -import React from "react"; +import React, { useState } from "react"; import { _ } from "~/i18n"; +import { ListSearch } from "~/components/core"; import { noop } from "~/utils"; /** @@ -72,19 +73,24 @@ const LocaleItem = ({ locale }) => { * changes. */ export default function LocaleSelector({ value, locales = [], onChange = noop }) { + const [filteredLocales, setFilteredLocales] = useState(locales); + return ( - - { locales.map((locale, index) => ( - onChange(locale.id)} - isSelected={locale.id === value} - className="cursor-pointer" - {...{ "data-type": "locale" }} - > - - - ))} - + <> + + + { filteredLocales.map((locale, index) => ( + onChange(locale.id)} + isSelected={locale.id === value} + className="cursor-pointer" + {...{ "data-type": "locale" }} + > + + + ))} + + ); } diff --git a/web/src/components/l10n/TimezoneSelector.jsx b/web/src/components/l10n/TimezoneSelector.jsx index 21da52ae35..7f51468ba0 100644 --- a/web/src/components/l10n/TimezoneSelector.jsx +++ b/web/src/components/l10n/TimezoneSelector.jsx @@ -19,9 +19,10 @@ * find current contact information at www.suse.com. */ -import React, { useEffect, useRef, useState } from "react"; +import React, { useState } from "react"; import { _ } from "~/i18n"; +import { ListSearch } from "~/components/core"; import { noop, timezoneTime } from "~/utils"; /** @@ -78,31 +79,6 @@ const TimezoneItem = ({ timezone, date }) => { ); }; -const useDebounce = (callback, delay) => { - const timeoutRef = useRef(null); - - useEffect(() => { - // Cleanup the previous timeout on re-render - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }; - }, []); - - const debouncedCallback = (...args) => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - - timeoutRef.current = setTimeout(() => { - callback(...args); - }, delay); - }; - - return debouncedCallback; -}; - /** * Component for selecting a timezone. * @component @@ -116,31 +92,11 @@ const useDebounce = (callback, delay) => { export default function TimezoneSelector({ value, timezones = [], onChange = noop }) { const displayTimezones = timezones.map(t => ({ ...t, details: timezoneDetails(t) })); const [filteredTimezones, setFilteredTimezones] = useState(displayTimezones); - - const search = useDebounce((term) => { - const filtered = displayTimezones.filter(timezone => { - const values = Object.values(timezone) - .join('') - .toLowerCase(); - return values.includes(term); - }); - - console.log("search: ", term); - setFilteredTimezones(filtered); - }, 500); - - const onSearchChange = (e) => { - const value = e.target.value; - search(value); - }; - const date = new Date(); return ( <> -
    - -
    + { filteredTimezones.map((timezone, index) => ( { return [value, setValue]; }; +/** + * Kudos to Sumit kumar Singh. + * See https://designtechworld.medium.com/create-a-custom-debounce-hook-in-react-114f3f245260 + */ + +const useDebounce = (callback, delay) => { + const timeoutRef = useRef(null); + + useEffect(() => { + // Cleanup the previous timeout on re-render + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const debouncedCallback = (...args) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + callback(...args); + }, delay); + }; + + return debouncedCallback; +}; + const hex = (value) => { const sanitizedValue = value.replaceAll(".", ""); return parseInt(sanitizedValue, 16); @@ -248,6 +278,7 @@ export { classNames, useCancellablePromise, useLocalStorage, + useDebounce, hex, toValidationError, locationReload, From b8bcd74613829aa21082f4f9ad68742d94769337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 24 Nov 2023 16:43:52 +0000 Subject: [PATCH 30/58] [web] Minor fixes --- web/src/components/l10n/KeymapSelector.jsx | 2 +- web/src/components/l10n/LocaleSelector.jsx | 4 ++-- web/src/utils.js | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/web/src/components/l10n/KeymapSelector.jsx b/web/src/components/l10n/KeymapSelector.jsx index 43f84cd9a6..2138fb9f39 100644 --- a/web/src/components/l10n/KeymapSelector.jsx +++ b/web/src/components/l10n/KeymapSelector.jsx @@ -56,7 +56,7 @@ const KeymapItem = ({ keymap }) => { return ( <>
    {keymap.name}
    -
    {keymap.id}
    +
    {keymap.id}
    ); }; diff --git a/web/src/components/l10n/LocaleSelector.jsx b/web/src/components/l10n/LocaleSelector.jsx index d402f83259..1bb871ec37 100644 --- a/web/src/components/l10n/LocaleSelector.jsx +++ b/web/src/components/l10n/LocaleSelector.jsx @@ -57,7 +57,7 @@ const LocaleItem = ({ locale }) => { <>
    {locale.name}
    {locale.territory}
    -
    {locale.id}
    +
    {locale.id}
    ); }; @@ -85,7 +85,7 @@ export default function LocaleSelector({ value, locales = [], onChange = noop }) onClick={() => onChange(locale.id)} isSelected={locale.id === value} className="cursor-pointer" - {...{ "data-type": "locale" }} + data-type="locale" >
    diff --git a/web/src/utils.js b/web/src/utils.js index bb9a55ef63..524dc8f2f3 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -163,8 +163,7 @@ const useLocalStorage = (storageKey, fallbackState) => { }; /** - * Kudos to Sumit kumar Singh. - * See https://designtechworld.medium.com/create-a-custom-debounce-hook-in-react-114f3f245260 + * Source https://designtechworld.medium.com/create-a-custom-debounce-hook-in-react-114f3f245260 */ const useDebounce = (callback, delay) => { From ea074d0b7122411d547367ac472e087a07a0a22c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 24 Nov 2023 16:53:54 +0000 Subject: [PATCH 31/58] [web] Minor improvements --- web/src/components/l10n/L10nPage.jsx | 2 +- web/src/components/overview/L10nSection.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/l10n/L10nPage.jsx b/web/src/components/l10n/L10nPage.jsx index f57d73bb24..229ee4684e 100644 --- a/web/src/components/l10n/L10nPage.jsx +++ b/web/src/components/l10n/L10nPage.jsx @@ -345,9 +345,9 @@ export default function L10nPage() { actionLabel={_("Back")} actionVariant="secondary" > - + ); } diff --git a/web/src/components/overview/L10nSection.jsx b/web/src/components/overview/L10nSection.jsx index feb7bb9129..04d191bd6c 100644 --- a/web/src/components/overview/L10nSection.jsx +++ b/web/src/components/overview/L10nSection.jsx @@ -34,7 +34,7 @@ const Content = ({ locales }) => { return ( - {msg1}{`${locale.name} (${locale.id})`}{msg2} + {msg1}{`${locale.name} (${locale.territory})`}{msg2} ); }; From f8a0c7016946fb5e5c8f69c7c785bc96282179c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 24 Nov 2023 16:44:32 +0000 Subject: [PATCH 32/58] Select the language packages for installation --- service/lib/agama/software/proposal.rb | 7 +++++-- service/test/agama/software/proposal_test.rb | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/service/lib/agama/software/proposal.rb b/service/lib/agama/software/proposal.rb index bcfd5395d9..c899a1e82f 100644 --- a/service/lib/agama/software/proposal.rb +++ b/service/lib/agama/software/proposal.rb @@ -136,8 +136,11 @@ def initialize_target Yast::Pkg.TargetFinish # ensure that previous target is closed Yast::Pkg.TargetInitialize(Yast::Installation.destdir) Yast::Pkg.TargetLoad - logger.info "Registering locales #{languages}" - Yast::Pkg.SetAdditionalLocales(languages) + + preferred, *additional = languages + Yast::Pkg.SetPackageLocale(preferred) if preferred + Yast::Pkg.SetAdditionalLocales(additional) + Yast::Pkg.SetSolverFlags("ignoreAlreadyRecommended" => false, "onlyRequires" => false) end diff --git a/service/test/agama/software/proposal_test.rb b/service/test/agama/software/proposal_test.rb index 396bc2cd37..1c3c176589 100644 --- a/service/test/agama/software/proposal_test.rb +++ b/service/test/agama/software/proposal_test.rb @@ -59,8 +59,9 @@ end it "selects the language packages" do + expect(Yast::Pkg).to receive(:SetPackageLocale).with("cs_CZ") expect(Yast::Pkg).to receive(:SetAdditionalLocales).with(["de_DE"]) - subject.languages = ["de_DE"] + subject.languages = ["cs_CZ", "de_DE"] subject.calculate end From d87d5890f280749565e1a615f800191612284d4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 24 Nov 2023 16:58:51 +0000 Subject: [PATCH 33/58] Add a dependency on xkeyboard-config-lang --- rust/package/agama-cli.spec | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rust/package/agama-cli.spec b/rust/package/agama-cli.spec index 9518cb35b3..1e200fa2b3 100644 --- a/rust/package/agama-cli.spec +++ b/rust/package/agama-cli.spec @@ -40,6 +40,8 @@ Requires: lshw # required by "agama logs store" Requires: bzip2 Requires: tar +# required for translating the keyboards descriptions +Requires: xkeyboard-config-lang %description Command line program to interact with the agama service. From 16e787bab67184e7fa121504157ee15ce14e6d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 28 Nov 2023 15:48:41 +0000 Subject: [PATCH 34/58] [web] Use role search for input --- web/src/assets/styles/blocks.scss | 4 ++-- web/src/components/core/ListSearch.jsx | 4 +--- web/src/components/l10n/KeymapSelector.jsx | 4 +++- web/src/components/l10n/LocaleSelector.jsx | 4 +++- web/src/components/l10n/TimezoneSelector.jsx | 4 +++- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index f3401e4db3..e98a1a5dfa 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -344,14 +344,14 @@ span.notification-mark[data-variant="sidebar"] { } [role="dialog"] { - [role="search"] { + .sticky-top-0 { position: sticky; top: calc(-1 * var(--pf-v5-c-modal-box__body--PaddingTop)); margin-top: calc(-1 * var(--pf-v5-c-modal-box__body--PaddingTop)); padding-top: var(--pf-v5-c-modal-box__body--PaddingTop); background-color: var(--pf-v5-c-modal-box--BackgroundColor); - > input { + [role="search"] { width: 100%; padding: var(--spacer-small); border: 1px solid var(--color-primary); diff --git a/web/src/components/core/ListSearch.jsx b/web/src/components/core/ListSearch.jsx index c4c7acd587..be35b91156 100644 --- a/web/src/components/core/ListSearch.jsx +++ b/web/src/components/core/ListSearch.jsx @@ -41,8 +41,6 @@ export default function ListSearch({ elements = [], onChange: onChangeProp = noo const onChange = (e) => searchHandler(e.target.value); return ( -
    - -
    + ); } diff --git a/web/src/components/l10n/KeymapSelector.jsx b/web/src/components/l10n/KeymapSelector.jsx index 2138fb9f39..c94f0ed0ba 100644 --- a/web/src/components/l10n/KeymapSelector.jsx +++ b/web/src/components/l10n/KeymapSelector.jsx @@ -76,7 +76,9 @@ export default function KeymapSelector({ value, keymaps = [], onChange = noop }) return ( <> - +
    + +
    { filteredKeymaps.map((keymap, index) => ( - +
    + +
    { filteredLocales.map((locale, index) => ( - +
    + +
    { filteredTimezones.map((timezone, index) => ( Date: Tue, 28 Nov 2023 15:49:39 +0000 Subject: [PATCH 35/58] [web] Rename button --- web/src/components/product/ProductPage.jsx | 2 +- web/src/components/product/ProductPage.test.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/product/ProductPage.jsx b/web/src/components/product/ProductPage.jsx index 77c671f855..fb10aa993b 100644 --- a/web/src/components/product/ProductPage.jsx +++ b/web/src/components/product/ProductPage.jsx @@ -236,7 +236,7 @@ const RegisteredWarningPopup = ({ isOpen = false, onAccept = noop }) => {

    - {_("Accept")} + {_("Close")} diff --git a/web/src/components/product/ProductPage.test.jsx b/web/src/components/product/ProductPage.test.jsx index 11a89fba2c..618d3e8994 100644 --- a/web/src/components/product/ProductPage.test.jsx +++ b/web/src/components/product/ProductPage.test.jsx @@ -247,7 +247,7 @@ describe("when the button for changing the product is clicked", () => { const popup = await screen.findByRole("dialog"); within(popup).getByText(/must be deregistered/); - const accept = within(popup).getByRole("button", { name: "Accept" }); + const accept = within(popup).getByRole("button", { name: "Close" }); await user.click(accept); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); From 2339b7101849e6374f5f841a4076ce013f2f3b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 28 Nov 2023 15:50:29 +0000 Subject: [PATCH 36/58] [web] Add tests --- web/src/components/core/ListSearch.test.jsx | 88 ++++ web/src/components/l10n/L10nPage.test.jsx | 378 ++++++++++++++++-- .../components/overview/L10nSection.test.jsx | 14 +- 3 files changed, 448 insertions(+), 32 deletions(-) create mode 100644 web/src/components/core/ListSearch.test.jsx diff --git a/web/src/components/core/ListSearch.test.jsx b/web/src/components/core/ListSearch.test.jsx new file mode 100644 index 0000000000..a2e03a4fa2 --- /dev/null +++ b/web/src/components/core/ListSearch.test.jsx @@ -0,0 +1,88 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useState } from "react"; +import { screen, waitFor } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { ListSearch } from "~/components/core"; + +const fruits = [ + { name: "apple", color: "red", size: "medium" }, + { name: "banana", color: "yellow", size: "medium" }, + { name: "grape", color: "green", size: "small" }, + { name: "pear", color: "green", size: "medium" } +]; + +const FruitList = ({ fruits }) => { + const [filteredFruits, setFilteredFruits] = useState(fruits); + + return ( + <> + +
      + {filteredFruits.map((f, i) =>
    • {f.name}
    • )} +
    + + ); +}; + +it("searches for elements matching the given text", async () => { + const { user } = plainRender(); + + const searchInput = screen.getByRole("search"); + + // Search for "medium" size fruit + await user.type(searchInput, "medium"); + await waitFor(() => ( + expect(screen.queryByRole("option", { name: /grape/ })).not.toBeInTheDocument()) + ); + screen.getByRole("option", { name: /apple/ }); + screen.getByRole("option", { name: /banana/ }); + screen.getByRole("option", { name: /pear/ }); + + // Search for "green" fruit + await user.clear(searchInput); + await user.type(searchInput, "green"); + await waitFor(() => ( + expect(screen.queryByRole("option", { name: /apple/ })).not.toBeInTheDocument()) + ); + await waitFor(() => ( + expect(screen.queryByRole("option", { name: /banana/ })).not.toBeInTheDocument()) + ); + screen.getByRole("option", { name: /grape/ }); + screen.getByRole("option", { name: /pear/ }); + + // Search for unknown fruit + await user.clear(searchInput); + await user.type(searchInput, "tomate"); + await waitFor(() => ( + expect(screen.queryByRole("option", { name: /apple/ })).not.toBeInTheDocument()) + ); + await waitFor(() => ( + expect(screen.queryByRole("option", { name: /banana/ })).not.toBeInTheDocument()) + ); + await waitFor(() => ( + expect(screen.queryByRole("option", { name: /grape/ })).not.toBeInTheDocument()) + ); + await waitFor(() => ( + expect(screen.queryByRole("option", { name: /pear/ })).not.toBeInTheDocument()) + ); +}); diff --git a/web/src/components/l10n/L10nPage.test.jsx b/web/src/components/l10n/L10nPage.test.jsx index 3f8fd0b724..5474cc8a1c 100644 --- a/web/src/components/l10n/L10nPage.test.jsx +++ b/web/src/components/l10n/L10nPage.test.jsx @@ -20,55 +20,377 @@ */ import React from "react"; -import { screen } from "@testing-library/react"; -import { installerRender, mockNavigateFn } from "~/test-utils"; +import { screen, waitFor, within } from "@testing-library/react"; + +import { installerRender } from "~/test-utils"; import { L10nPage } from "~/components/l10n"; import { createClient } from "~/client"; -const setLanguagesFn = jest.fn(); -const languages = [ - { id: "en_US", name: "English" }, - { id: "de_DE", name: "German" } +const locales = [ + { id: "de_DE.UTF8", name: "German", territory: "Germany" }, + { id: "en_US.UTF8", name: "English", territory: "United States" }, + { id: "es_ES.UTF8", name: "Spanish", territory: "Spain" } +]; + +const keymaps = [ + { id: "de", name: "German" }, + { id: "us", name: "English" }, + { id: "es", name: "Spanish" } +]; + +const timezones = [ + { id: "asia/bangkok", parts: ["Asia", "Bangkok"] }, + { id: "atlantic/canary", parts: ["Atlantic", "Canary"] }, + { id: "america/new_york", parts: ["Americas", "New York"] } ]; +let mockL10nClient; +let mockSelectedLocales; +let mockSelectedKeymap; +let mockSelectedTimezone; + jest.mock("~/client"); +jest.mock("~/context/l10n", () => ({ + ...jest.requireActual("~/context/l10n"), + useL10n: () => ({ + locales, + selectedLocales: mockSelectedLocales, + keymaps, + selectedKeymap: mockSelectedKeymap, + timezones, + selectedTimezone: mockSelectedTimezone + }) +})); + +createClient.mockImplementation(() => ( + { + l10n: mockL10nClient + } +)); + beforeEach(() => { - // if defined outside, the mock is cleared automatically - createClient.mockImplementation(() => { - return { - language: { - getLanguages: () => Promise.resolve(languages), - getSelectedLanguages: () => Promise.resolve(["en_US"]), - setLanguages: setLanguagesFn, - } - }; + mockL10nClient = { + setLocales: jest.fn().mockResolvedValue(), + setKeymap: jest.fn().mockResolvedValue(), + setTimezone: jest.fn().mockResolvedValue() + }; + + mockSelectedLocales = []; + mockSelectedKeymap = undefined; + mockSelectedTimezone = undefined; +}); + +it("renders a section for configuring the language", () => { + installerRender(); + screen.getByText("Language"); +}); + +describe("if there is no selected language", () => { + beforeEach(() => { + mockSelectedLocales = []; + }); + + it("renders a button for selecting a language", () => { + installerRender(); + screen.getByText("Language not selected yet"); + screen.getByRole("button", { name: "Select language" }); + }); +}); + +describe("if there is a selected language", () => { + beforeEach(() => { + mockSelectedLocales = [{ id: "es_ES.UTF8", name: "Spanish", territory: "Spain" }]; + }); + + it("renders a button for changing the language", () => { + installerRender(); + screen.getByText("Spanish - Spain"); + screen.getByRole("button", { name: "Change language" }); + }); +}); + +describe("when the button for changing the language is clicked", () => { + beforeEach(() => { + mockSelectedLocales = [{ id: "es_ES.UTF8", name: "Spanish", territory: "Spain" }]; + }); + + it("opens a popup for selecting the language", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change language" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Select language"); + within(popup).getByRole("option", { name: /German/ }); + within(popup).getByRole("option", { name: /English/ }); + within(popup).getByRole("option", { name: /Spanish/, selected: true }); + }); + + it("allows filtering languages", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change language" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const searchInput = within(popup).getByRole("search"); + + await user.type(searchInput, "ish"); + + await waitFor(() => ( + expect(within(popup).queryByRole("option", { name: /German/ })).not.toBeInTheDocument()) + ); + within(popup).getByRole("option", { name: /English/ }); + within(popup).getByRole("option", { name: /Spanish/ }); + }); + + describe("if the popup is canceled", () => { + it("closes the popup without selecting a new language", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change language" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const option = within(popup).getByRole("option", { name: /English/ }); + + await user.click(option); + const cancel = within(popup).getByRole("button", { name: "Cancel" }); + await user.click(cancel); + + expect(mockL10nClient.setLocales).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + describe("if the popup is accepted", () => { + it("closes the popup selecting the new language", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change language" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const option = within(popup).getByRole("option", { name: /English/ }); + + await user.click(option); + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + expect(mockL10nClient.setLocales).toHaveBeenCalledWith(["en_US.UTF8"]); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); +}); + +it("renders a section for configuring the keyboard", () => { + installerRender(); + screen.getByText("Keyboard"); +}); + +describe("if there is no selected keyboard", () => { + beforeEach(() => { + mockSelectedKeymap = undefined; + }); + + it("renders a button for selecting a keyboard", () => { + installerRender(); + screen.getByText("Keyboard not selected yet"); + screen.getByRole("button", { name: "Select keyboard" }); + }); +}); + +describe("if there is a selected keyboard", () => { + beforeEach(() => { + mockSelectedKeymap = { id: "es", name: "Spanish" }; + }); + + it("renders a button for changing the keyboard", () => { + installerRender(); + screen.getByText("Spanish"); + screen.getByRole("button", { name: "Change keyboard" }); }); }); -it("displays the language selector", async () => { +describe("when the button for changing the keyboard is clicked", () => { + beforeEach(() => { + mockSelectedKeymap = { id: "es", name: "Spanish" }; + }); + + it("opens a popup for selecting the keyboard", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change keyboard" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Select keyboard"); + within(popup).getByRole("option", { name: /German/ }); + within(popup).getByRole("option", { name: /English/ }); + within(popup).getByRole("option", { name: /Spanish/, selected: true }); + }); + + it("allows filtering keyboards", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change keyboard" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const searchInput = within(popup).getByRole("search"); + + await user.type(searchInput, "ish"); + + await waitFor(() => ( + expect(within(popup).queryByRole("option", { name: /German/ })).not.toBeInTheDocument()) + ); + within(popup).getByRole("option", { name: /English/ }); + within(popup).getByRole("option", { name: /Spanish/ }); + }); + + describe("if the popup is canceled", () => { + it("closes the popup without selecting a new keyboard", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change keyboard" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const option = within(popup).getByRole("option", { name: /English/ }); + + await user.click(option); + const cancel = within(popup).getByRole("button", { name: "Cancel" }); + await user.click(cancel); + + expect(mockL10nClient.setKeymap).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + describe("if the popup is accepted", () => { + it("closes the popup selecting the new keyboard", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change keyboard" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const option = within(popup).getByRole("option", { name: /English/ }); + + await user.click(option); + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + expect(mockL10nClient.setKeymap).toHaveBeenCalledWith("us"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); +}); + +it("renders a section for configuring the time zone", () => { installerRender(); + screen.getByText("Time zone"); +}); + +describe("if there is no selected time zone", () => { + beforeEach(() => { + mockSelectedTimezone = undefined; + }); + + it("renders a button for selecting a time zone", () => { + installerRender(); + screen.getByText("Time zone not selected yet"); + screen.getByRole("button", { name: "Select time zone" }); + }); +}); + +describe("if there is a selected time zone", () => { + beforeEach(() => { + mockSelectedTimezone = { id: "atlantic/canary", parts: ["Atlantic", "Canary"] }; + }); - await screen.findByLabelText("Language"); + it("renders a button for changing the time zone", () => { + installerRender(); + screen.getByText("Atlantic - Canary"); + screen.getByRole("button", { name: "Change time zone" }); + }); }); -describe("when the user accept changes", () => { - it("changes the selected language", async () => { +describe("when the button for changing the time zone is clicked", () => { + beforeEach(() => { + mockSelectedTimezone = { id: "atlantic/canary", parts: ["Atlantic", "Canary"] }; + }); + + it("opens a popup for selecting the time zone", async () => { const { user } = installerRender(); - const germanOption = await screen.findByRole("option", { name: "German" }); - const acceptButton = screen.getByRole("button", { name: "Accept" }); - await user.selectOptions(screen.getByLabelText("Language"), germanOption); - await user.click(acceptButton); + const button = screen.getByRole("button", { name: "Change time zone" }); + await user.click(button); - expect(setLanguagesFn).toHaveBeenCalledWith(["de_DE"]); + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Select time zone"); + within(popup).getByRole("option", { name: /Bangkok/ }); + within(popup).getByRole("option", { name: /Canary/, selected: true }); + within(popup).getByRole("option", { name: /New York/ }); }); - it("navigates to the root path", async () => { + it("allows filtering time zones", async () => { const { user } = installerRender(); - const acceptButton = screen.getByRole("button", { name: "Accept" }); - await user.click(acceptButton); - expect(mockNavigateFn).toHaveBeenCalledWith("/"); + const button = screen.getByRole("button", { name: "Change time zone" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const searchInput = within(popup).getByRole("search"); + + await user.type(searchInput, "new"); + + await waitFor(() => ( + expect(within(popup).queryByRole("option", { name: /Bangkok/ })).not.toBeInTheDocument()) + ); + await waitFor(() => ( + expect(within(popup).queryByRole("option", { name: /Canary/ })).not.toBeInTheDocument()) + ); + within(popup).getByRole("option", { name: /New York/ }); + }); + + describe("if the popup is canceled", () => { + it("closes the popup without selecting a new time zone", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change time zone" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const option = within(popup).getByRole("option", { name: /New York/ }); + + await user.click(option); + const cancel = within(popup).getByRole("button", { name: "Cancel" }); + await user.click(cancel); + + expect(mockL10nClient.setTimezone).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + describe("if the popup is accepted", () => { + it("closes the popup selecting the new time zone", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change time zone" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const option = within(popup).getByRole("option", { name: /Bangkok/ }); + + await user.click(option); + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + expect(mockL10nClient.setTimezone).toHaveBeenCalledWith("asia/bangkok"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); }); }); diff --git a/web/src/components/overview/L10nSection.test.jsx b/web/src/components/overview/L10nSection.test.jsx index 78e6c57097..e5da1c1088 100644 --- a/web/src/components/overview/L10nSection.test.jsx +++ b/web/src/components/overview/L10nSection.test.jsx @@ -36,7 +36,13 @@ const l10nClientMock = { locales: jest.fn().mockResolvedValue(locales), getLocales: jest.fn().mockResolvedValue(["en_US"]), getUILocale: jest.fn().mockResolvedValue("en_US"), - onLocalesChange: jest.fn() + keymaps: jest.fn().mockResolvedValue([]), + getKeymap: jest.fn().mockResolvedValue(undefined), + timezones: jest.fn().mockResolvedValue([]), + getTimezone: jest.fn().mockResolvedValue(undefined), + onLocalesChange: jest.fn(), + onKeymapChange: jest.fn(), + onTimezoneChange: jest.fn() }; beforeEach(() => { @@ -51,7 +57,7 @@ beforeEach(() => { it("displays the selected locale", async () => { installerRender(, { withL10n: true }); - await screen.findByText("English (en_US)"); + await screen.findByText("English (United States)"); }); describe("when the selected locales change", () => { @@ -60,11 +66,11 @@ describe("when the selected locales change", () => { l10nClientMock.onLocalesChange = mockFunction; installerRender(, { withL10n: true }); - await screen.findByText("English (en_US)"); + await screen.findByText("English (United States)"); const [cb] = callbacks; act(() => cb(["de_DE"])); - await screen.findByText("German (de_DE)"); + await screen.findByText("German (Germany)"); }); }); From 4c71a001f43efb6673913fba77a5f792c5ae7bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 28 Nov 2023 16:15:40 +0000 Subject: [PATCH 37/58] [web] Indicate product name --- web/src/components/core/ListSearch.test.jsx | 2 +- web/src/components/l10n/L10nPage.jsx | 13 +++++++++---- web/src/components/l10n/L10nPage.test.jsx | 7 +++++++ web/src/components/overview/L10nSection.jsx | 3 ++- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/web/src/components/core/ListSearch.test.jsx b/web/src/components/core/ListSearch.test.jsx index a2e03a4fa2..9e6659a2e5 100644 --- a/web/src/components/core/ListSearch.test.jsx +++ b/web/src/components/core/ListSearch.test.jsx @@ -72,7 +72,7 @@ it("searches for elements matching the given text", async () => { // Search for unknown fruit await user.clear(searchInput); - await user.type(searchInput, "tomate"); + await user.type(searchInput, "tomato"); await waitFor(() => ( expect(screen.queryByRole("option", { name: /apple/ })).not.toBeInTheDocument()) ); diff --git a/web/src/components/l10n/L10nPage.jsx b/web/src/components/l10n/L10nPage.jsx index 229ee4684e..56367cdd00 100644 --- a/web/src/components/l10n/L10nPage.jsx +++ b/web/src/components/l10n/L10nPage.jsx @@ -21,6 +21,7 @@ import React, { useState } from "react"; import { Button, Form } from "@patternfly/react-core"; +import { sprintf } from "sprintf-js"; import { useInstallerClient } from "~/context/installer"; import { _ } from "~/i18n"; @@ -28,6 +29,7 @@ import { If, Page, Popup, Section } from "~/components/core"; import { KeymapSelector, LocaleSelector, TimezoneSelector } from "~/components/l10n"; import { noop } from "~/utils"; import { useL10n } from "~/context/l10n"; +import { useProduct } from "~/context/product"; /** * Popup for selecting a timezone. @@ -40,8 +42,9 @@ import { useL10n } from "~/context/l10n"; const TimezonePopup = ({ onFinish = noop, onCancel = noop }) => { const { l10n } = useInstallerClient(); const { timezones, selectedTimezone } = useL10n(); - const [timezoneId, setTimezoneId] = useState(selectedTimezone?.id); + const [timezoneId, setTimezoneId] = useState(selectedTimezone?.id); + const { selectedProduct } = useProduct(); const sortedTimezones = timezones.sort((timezone1, timezone2) => { const timezoneText = t => t.parts.join('').toLowerCase(); return timezoneText(timezone1) > timezoneText(timezone2) ? 1 : -1; @@ -61,7 +64,7 @@ const TimezonePopup = ({ onFinish = noop, onCancel = noop }) => { @@ -142,6 +145,7 @@ const TimezoneSection = () => { const LocalePopup = ({ onFinish = noop, onCancel = noop }) => { const { l10n } = useInstallerClient(); const { locales, selectedLocales } = useL10n(); + const { selectedProduct } = useProduct(); const [localeId, setLocaleId] = useState(selectedLocales[0]?.id); const sortedLocales = locales.sort((locale1, locale2) => { @@ -165,7 +169,7 @@ const LocalePopup = ({ onFinish = noop, onCancel = noop }) => { @@ -248,6 +252,7 @@ const LocaleSection = () => { const KeymapPopup = ({ onFinish = noop, onCancel = noop }) => { const { l10n } = useInstallerClient(); const { keymaps, selectedKeymap } = useL10n(); + const { selectedProduct } = useProduct(); const [keymapId, setKeymapId] = useState(selectedKeymap?.id); const sortedKeymaps = keymaps.sort((k1, k2) => k1.name > k2.name ? 1 : -1); @@ -266,7 +271,7 @@ const KeymapPopup = ({ onFinish = noop, onCancel = noop }) => { diff --git a/web/src/components/l10n/L10nPage.test.jsx b/web/src/components/l10n/L10nPage.test.jsx index 5474cc8a1c..7423b0b284 100644 --- a/web/src/components/l10n/L10nPage.test.jsx +++ b/web/src/components/l10n/L10nPage.test.jsx @@ -63,6 +63,13 @@ jest.mock("~/context/l10n", () => ({ }) })); +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => ({ + selectedProduct : { name: "Test" } + }) +})); + createClient.mockImplementation(() => ( { l10n: mockL10nClient diff --git a/web/src/components/overview/L10nSection.jsx b/web/src/components/overview/L10nSection.jsx index 04d191bd6c..de6d1b5eb5 100644 --- a/web/src/components/overview/L10nSection.jsx +++ b/web/src/components/overview/L10nSection.jsx @@ -29,7 +29,8 @@ const Content = ({ locales }) => { // Only considering the first locale. const locale = locales[0]; - // TRANSLATORS: %s will be replaced by a language name and code, example: "English (en_US.UTF-8)". + // TRANSLATORS: %s will be replaced by a language name and territory, example: + // "English (United States)". const [msg1, msg2] = _("The system will use %s as its default language.").split("%s"); return ( From 1adb89d5352d6dca891150252b941ef4e5673a67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 28 Nov 2023 17:41:59 +0000 Subject: [PATCH 38/58] [web] Small fixes --- web/src/App.test.jsx | 4 +++- web/src/components/storage/device-utils.jsx | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx index fb7695a70e..0e6bab251d 100644 --- a/web/src/App.test.jsx +++ b/web/src/App.test.jsx @@ -75,7 +75,9 @@ describe("App", () => { getLocales: jest.fn().mockResolvedValue(["en_us"]), getUILocale: jest.fn().mockResolvedValue("en_us"), setUILocale: jest.fn().mockResolvedValue("en_us"), - onLocalesChange: jest.fn() + onTimezoneChange: jest.fn(), + onLocalesChange: jest.fn(), + onKeymapChange: jest.fn() } }; }); diff --git a/web/src/components/storage/device-utils.jsx b/web/src/components/storage/device-utils.jsx index a1338d40b9..0e3d633215 100644 --- a/web/src/components/storage/device-utils.jsx +++ b/web/src/components/storage/device-utils.jsx @@ -215,7 +215,7 @@ const DeviceItem = ({ device }) => { return ( <> - + @@ -233,7 +233,7 @@ const DeviceList = ({ devices }) => { return ( { devices.map(device => ( - + ))} @@ -275,7 +275,7 @@ const DeviceSelector = ({ devices, selected, isMultiple = false, onChange = noop onClick={() => onOptionClick(device.name)} isSelected={isSelected(device.name)} className="cursor-pointer" - {...{ "data-type": "storage-device" }} + data-type="storage-device" > From dc435f65422ebe24eca560a5bdc89c59aad327e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 28 Nov 2023 17:46:53 +0000 Subject: [PATCH 39/58] [web] Documentation --- web/src/components/core/ListSearch.jsx | 8 +++++ web/src/components/l10n/L10nPage.jsx | 37 ++++++++++++++++++++++ web/src/components/l10n/LocaleSelector.jsx | 2 +- web/src/context/installerL10n.jsx | 3 +- web/src/context/l10n.jsx | 20 ++++++++++++ web/src/context/product.jsx | 17 ++++++++++ web/src/utils.js | 32 +++++++++++++++++-- 7 files changed, 115 insertions(+), 4 deletions(-) diff --git a/web/src/components/core/ListSearch.jsx b/web/src/components/core/ListSearch.jsx index be35b91156..c859b37aa7 100644 --- a/web/src/components/core/ListSearch.jsx +++ b/web/src/components/core/ListSearch.jsx @@ -35,6 +35,14 @@ const search = (elements, term) => { return elements.filter(match); }; +/** + * Input field for searching in a given list of elements. + * @component + * + * @param {object} props + * @param {object[]} [props.elements] - List of element in which to search. + * @param {(elements: object[]) => void} - Callback to be called with the filtered list of elements. + */ export default function ListSearch({ elements = [], onChange: onChangeProp = noop }) { const searchHandler = useDebounce(term => onChangeProp(search(elements, term)), 500); diff --git a/web/src/components/l10n/L10nPage.jsx b/web/src/components/l10n/L10nPage.jsx index 56367cdd00..4121631782 100644 --- a/web/src/components/l10n/L10nPage.jsx +++ b/web/src/components/l10n/L10nPage.jsx @@ -80,6 +80,13 @@ const TimezonePopup = ({ onFinish = noop, onCancel = noop }) => { ); }; +/** + * Button for opening the selection of timezone. + * @component + * + * @param {object} props + * @param {React.ReactNode} props.children - Button children. + */ const TimezoneButton = ({ children }) => { const [isPopupOpen, setIsPopupOpen] = useState(false); @@ -110,6 +117,10 @@ const TimezoneButton = ({ children }) => { ); }; +/** + * Section for configuring timezone. + * @component + */ const TimezoneSection = () => { const { selectedTimezone } = useL10n(); @@ -185,6 +196,13 @@ const LocalePopup = ({ onFinish = noop, onCancel = noop }) => { ); }; +/** + * Button for opening the selection of locales. + * @component + * + * @param {object} props + * @param {React.ReactNode} props.children - Button children. + */ const LocaleButton = ({ children }) => { const [isPopupOpen, setIsPopupOpen] = useState(false); @@ -215,6 +233,10 @@ const LocaleButton = ({ children }) => { ); }; +/** + * Section for configuring locales. + * @component + */ const LocaleSection = () => { const { selectedLocales } = useL10n(); @@ -287,6 +309,13 @@ const KeymapPopup = ({ onFinish = noop, onCancel = noop }) => { ); }; +/** + * Button for opening the selection of keymaps. + * @component + * + * @param {object} props + * @param {React.ReactNode} props.children - Button children. + */ const KeymapButton = ({ children }) => { const [isPopupOpen, setIsPopupOpen] = useState(false); @@ -317,6 +346,10 @@ const KeymapButton = ({ children }) => { ); }; +/** + * Section for configuring keymaps. + * @component + */ const KeymapSection = () => { const { selectedKeymap } = useL10n(); @@ -341,6 +374,10 @@ const KeymapSection = () => { ); }; +/** + * Page for configuring localization. + * @component + */ export default function L10nPage() { return ( { }; /** - * Content for a locale item + * Content for a locale item. * @component * * @param {Object} props diff --git a/web/src/context/installerL10n.jsx b/web/src/context/installerL10n.jsx index df6826ac4c..482843a274 100644 --- a/web/src/context/installerL10n.jsx +++ b/web/src/context/installerL10n.jsx @@ -29,6 +29,8 @@ import { useInstallerClient } from "./installer"; const L10nContext = React.createContext(null); /** + * Installer localization context. + * * @typedef {object} L10nContext * @property {string|undefined} language - Current language. * @property {(language: string) => void} changeLanguage - Function to change the current language. @@ -188,7 +190,6 @@ function reload(newLanguage) { * * @param {object} props * @param {React.ReactNode} [props.children] - Content to display within the wrapper. - * @param {import("~/client").InstallerClient} [props.client] - Client. * * @see useInstallerL10n */ diff --git a/web/src/context/l10n.jsx b/web/src/context/l10n.jsx index ac0a6c2f06..7948ed70c6 100644 --- a/web/src/context/l10n.jsx +++ b/web/src/context/l10n.jsx @@ -23,6 +23,12 @@ import React, { useContext, useEffect, useState } from "react"; import { useCancellablePromise } from "~/utils"; import { useInstallerClient } from "./installer"; +/** + * @typedef {import ("~/clients/l10n").Locale} Locale + * @typedef {import ("~/clients/l10n").Keymap} Keymap + * @typedef {import ("~/clients/l10n").Timezone} Timezone + */ + const L10nContext = React.createContext({}); function L10nProvider({ children }) { @@ -78,6 +84,20 @@ function L10nProvider({ children }) { return {children}; } +/** + * Localization context. + * @function + * + * @typedef {object} L10nContext + * @property {Locale[]} locales + * @property {Keymap[]} keymaps + * @property {Timezone[]} timezones + * @property {Locale[]} selectedLocales + * @property {Keymap|undefined} selectedKeymap + * @property {Timezone|undefined} selectedTimezone + * + * @returns {L10nContext} + */ function useL10n() { const context = useContext(L10nContext); diff --git a/web/src/context/product.jsx b/web/src/context/product.jsx index 4f476f710a..1a4387c190 100644 --- a/web/src/context/product.jsx +++ b/web/src/context/product.jsx @@ -23,6 +23,11 @@ import React, { useContext, useEffect, useState } from "react"; import { useCancellablePromise } from "~/utils"; import { useInstallerClient } from "./installer"; +/** + * @typedef {import ("~/clients/software").Product} Product + * @typedef {import ("~/clients/software").Registration} Registration + */ + const ProductContext = React.createContext([]); function ProductProvider({ children }) { @@ -64,6 +69,18 @@ function ProductProvider({ children }) { return {children}; } +/** + * Product context. + * @function + * + * @typedef {object} ProductContext + * @property {Product[]} products + * @property {Product|null} selectedProduct + * @property {string} selectedId + * @property {Registration} registration + * + * @returns {ProductContext} + */ function useProduct() { const context = useContext(ProductContext); diff --git a/web/src/utils.js b/web/src/utils.js index 524dc8f2f3..7a4d84afff 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -163,9 +163,21 @@ const useLocalStorage = (storageKey, fallbackState) => { }; /** - * Source https://designtechworld.medium.com/create-a-custom-debounce-hook-in-react-114f3f245260 + * Debounce hook. + * @function + * + * Source {@link https://designtechworld.medium.com/create-a-custom-debounce-hook-in-react-114f3f245260} + * + * @param {function} callback - Function to be called after some delay. + * @param {number} delay - Delay in milliseconds. + * @returns {function} + * + * @example + * + * const log = useDebounce(console.log, 1000); + * log("test ", 1) // The message will be logged after 1 second. + * log("test ", 2) // Subsequent calls cancels pending calls. */ - const useDebounce = (callback, delay) => { const timeoutRef = useRef(null); @@ -237,6 +249,16 @@ const setLocationSearch = (query) => { window.location.search = query; }; +/** + * Time for the given timezone. + * + * @param {string} timezone - E.g., "Atlantic/Canary". + * @param {object} [options] + * @param {Date} options.date - Date to take the time from. + * + * @returns {string|undefined} - Time in 24 hours format (e.g., "23:56"). Undefined for an unknown + * timezone. + */ const timezoneTime = (timezone, { date = new Date() }) => { try { const formatter = new Intl.DateTimeFormat( @@ -252,6 +274,12 @@ const timezoneTime = (timezone, { date = new Date() }) => { } }; +/** + * UTC offset for the given timezone. + * + * @param {string} timezone - E.g., "Atlantic/Canary". + * @returns {number|undefined} - undefined for an unknown timezone. + */ const timezoneUTCOffset = (timezone) => { try { const date = new Date(); From e31ca0203124fceaa65f310e63e431c120cc4e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 28 Nov 2023 07:04:39 +0000 Subject: [PATCH 40/58] Move locale code to its own module --- rust/agama-dbus-server/src/lib.rs | 2 -- rust/agama-dbus-server/src/locale.rs | 7 +++++-- rust/agama-dbus-server/src/{ => locale}/helpers.rs | 0 rust/agama-dbus-server/src/{ => locale}/keyboard.rs | 0 rust/agama-dbus-server/src/main.rs | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) rename rust/agama-dbus-server/src/{ => locale}/helpers.rs (100%) rename rust/agama-dbus-server/src/{ => locale}/keyboard.rs (100%) diff --git a/rust/agama-dbus-server/src/lib.rs b/rust/agama-dbus-server/src/lib.rs index 91bc755a7c..a4c9dc66c8 100644 --- a/rust/agama-dbus-server/src/lib.rs +++ b/rust/agama-dbus-server/src/lib.rs @@ -1,6 +1,4 @@ pub mod error; -pub mod helpers; -pub mod keyboard; pub mod locale; pub mod network; pub mod questions; diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index e1e4c257e2..cc9905baa7 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -1,5 +1,8 @@ -use super::{helpers, keyboard::get_keymaps}; -use crate::{error::Error, keyboard::Keymap}; +mod keyboard; +pub mod helpers; + +use keyboard::{get_keymaps, Keymap}; +use crate::error::Error; use agama_locale_data::{KeymapId, LocaleCode}; use anyhow::Context; use std::process::Command; diff --git a/rust/agama-dbus-server/src/helpers.rs b/rust/agama-dbus-server/src/locale/helpers.rs similarity index 100% rename from rust/agama-dbus-server/src/helpers.rs rename to rust/agama-dbus-server/src/locale/helpers.rs diff --git a/rust/agama-dbus-server/src/keyboard.rs b/rust/agama-dbus-server/src/locale/keyboard.rs similarity index 100% rename from rust/agama-dbus-server/src/keyboard.rs rename to rust/agama-dbus-server/src/locale/keyboard.rs diff --git a/rust/agama-dbus-server/src/main.rs b/rust/agama-dbus-server/src/main.rs index cf615fd0a6..7652c1fc82 100644 --- a/rust/agama-dbus-server/src/main.rs +++ b/rust/agama-dbus-server/src/main.rs @@ -1,4 +1,4 @@ -use agama_dbus_server::{helpers, locale, network, questions}; +use agama_dbus_server::{locale, locale::helpers, network, questions}; use agama_lib::connection_to; use anyhow::Context; From a866bbcefafcd9ed0ff31db140bc40d6694211e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 28 Nov 2023 21:57:25 +0000 Subject: [PATCH 41/58] Move locale and timezone handling to its own modules --- rust/agama-dbus-server/src/error.rs | 6 + rust/agama-dbus-server/src/locale.rs | 165 ++++++++---------- rust/agama-dbus-server/src/locale/locale.rs | 136 +++++++++++++++ rust/agama-dbus-server/src/locale/timezone.rs | 99 +++++++++++ rust/agama-locale-data/src/locale.rs | 7 + 5 files changed, 316 insertions(+), 97 deletions(-) create mode 100644 rust/agama-dbus-server/src/locale/locale.rs create mode 100644 rust/agama-dbus-server/src/locale/timezone.rs diff --git a/rust/agama-dbus-server/src/error.rs b/rust/agama-dbus-server/src/error.rs index 81202acd14..926feab063 100644 --- a/rust/agama-dbus-server/src/error.rs +++ b/rust/agama-dbus-server/src/error.rs @@ -19,3 +19,9 @@ impl From for Error { Self::Anyhow(format!("{:#}", e)) } } + +impl From for zbus::fdo::Error { + fn from(value: Error) -> zbus::fdo::Error { + zbus::fdo::Error::Failed(format!("Localization error: {value}")) + } +} diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index cc9905baa7..d94b102ba9 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -1,20 +1,25 @@ -mod keyboard; pub mod helpers; +mod keyboard; +mod locale; +mod timezone; -use keyboard::{get_keymaps, Keymap}; use crate::error::Error; -use agama_locale_data::{KeymapId, LocaleCode}; +use agama_locale_data::KeymapId; use anyhow::Context; +use keyboard::{get_keymaps, Keymap}; +use locale::LocalesDatabase; use std::process::Command; +use timezone::TimezonesDatabase; use zbus::{dbus_interface, Connection}; pub struct Locale { + timezone: String, + timezones_db: TimezonesDatabase, locales: Vec, - timezone_id: String, - supported_locales: Vec, - ui_locale: String, + locales_db: LocalesDatabase, keymap: KeymapId, keymaps: Vec, + ui_locale: String, } #[dbus_interface(name = "org.opensuse.Agama1.Locale")] @@ -31,46 +36,19 @@ impl Locale { /// // NOTE: check how often it is used and if often, it can be easily cached fn list_locales(&self) -> Result, Error> { - const DEFAULT_LANG: &str = "en"; - let mut result = Vec::with_capacity(self.supported_locales.len()); - let languages = agama_locale_data::get_languages()?; - let territories = agama_locale_data::get_territories()?; - for code in self.supported_locales.as_slice() { - let Ok(loc) = TryInto::::try_into(code.as_str()) else { - log::debug!("Ignoring locale code {}", &code); - continue; - }; - - let ui_language = self - .ui_locale - .split_once("_") - .map(|(l, _)| l) - .unwrap_or(DEFAULT_LANG); - - let language = languages - .find_by_id(&loc.language) - .context("language not found")?; - - let names = &language.names; - let language_label = names - .name_for(&ui_language) - .or_else(|| names.name_for(DEFAULT_LANG)) - .unwrap_or(language.id.to_string()); - - let territory = territories - .find_by_id(&loc.territory) - .context("territory not found")?; - - let names = &territory.names; - let territory_label = names - .name_for(&ui_language) - .or_else(|| names.name_for(DEFAULT_LANG)) - .unwrap_or(territory.id.to_string()); - - result.push((code.clone(), language_label, territory_label)) - } - - Ok(result) + let locales = self + .locales_db + .entries() + .iter() + .map(|l| { + ( + l.code.to_string(), + l.language.to_string(), + l.territory.to_string(), + ) + }) + .collect::>(); + Ok(locales) } #[dbus_interface(property)] @@ -81,7 +59,7 @@ impl Locale { #[dbus_interface(property)] fn set_locales(&mut self, locales: Vec) -> zbus::fdo::Result<()> { for loc in &locales { - if !self.supported_locales.contains(loc) { + if !self.locales_db.exists(loc.as_str()) { return Err(zbus::fdo::Error::Failed(format!( "Unsupported locale value '{loc}'" ))); @@ -97,9 +75,9 @@ impl Locale { } #[dbus_interface(property, name = "UILocale")] - fn set_ui_locale(&mut self, locale: &str) { - self.ui_locale = locale.to_string(); + fn set_ui_locale(&mut self, locale: &str) -> zbus::fdo::Result<()> { helpers::set_service_locale(locale); + Ok(self.translate(locale)?) } /// Returns a list of the supported keymaps. @@ -145,35 +123,29 @@ impl Locale { /// * A list containing each part of the name in the language set by the /// UILocale property. fn list_timezones(&self) -> Result)>, Error> { - let language = self.ui_locale.split("_").next().unwrap_or(&self.ui_locale); - let timezones = agama_locale_data::get_timezones(); - let tz_parts = agama_locale_data::get_timezone_parts()?; - let ret = timezones - .into_iter() - .map(|tz| { - let parts: Vec<_> = tz - .split("/") - .map(|part| { - tz_parts - .localize_part(part, &language) - .unwrap_or(part.to_owned()) - }) - .collect(); - (tz, parts) - }) + let timezones: Vec<_> = self + .timezones_db + .entries() + .iter() + .map(|tz| (tz.code.to_string(), tz.parts.clone())) .collect(); - Ok(ret) + Ok(timezones) } #[dbus_interface(property)] fn timezone(&self) -> &str { - self.timezone_id.as_str() + self.timezone.as_str() } #[dbus_interface(property)] fn set_timezone(&mut self, timezone: &str) -> Result<(), zbus::fdo::Error> { - // NOTE: cannot use crate::Error as property expect this one - self.timezone_id = timezone.to_string(); + let timezone = timezone.to_string(); + if !self.timezones_db.exists(&timezone) { + return Err(zbus::fdo::Error::Failed(format!( + "Unsupported timezone value '{timezone}'" + ))); + } + self.timezone = timezone; Ok(()) } @@ -194,7 +166,7 @@ impl Locale { .status() .context("Failed to execute systemd-firstboot")?; Command::new("/usr/bin/systemd-firstboot") - .args(["--root", ROOT, "--timezone", self.timezone_id.as_str()]) + .args(["--root", ROOT, "--timezone", self.timezone.as_str()]) .status() .context("Failed to execute systemd-firstboot")?; @@ -203,36 +175,35 @@ impl Locale { } impl Locale { - pub fn from_system() -> Result { - let result = Command::new("/usr/bin/localectl") - .args(["list-locales"]) - .output() - .context("Failed to get the list of locales")?; - let output = - String::from_utf8(result.stdout).context("Invalid UTF-8 sequence from list-locales")?; - let supported: Vec = output.lines().map(|s| s.to_string()).collect(); - Ok(Self { - supported_locales: supported, + pub fn new_with_locale(locale: &str) -> Result { + let mut locales_db = LocalesDatabase::new(); + locales_db.read(&locale)?; + let default_locale = locales_db.entries().get(0).unwrap(); + + let mut timezones_db = TimezonesDatabase::new(); + timezones_db.read(&locale)?; + let default_timezone = timezones_db.entries().get(0).unwrap(); + + let locale = Self { + keymap: "us".parse().unwrap(), + timezone: default_timezone.code.to_string(), + locales: vec![default_locale.code.to_string()], + locales_db, + timezones_db, keymaps: get_keymaps()?, - ..Default::default() - }) - } + ui_locale: locale.to_string(), + }; - pub fn init(&mut self) -> Result<(), Box> { - Ok(()) + Ok(locale) } -} -impl Default for Locale { - fn default() -> Self { - Self { - locales: vec!["en_US.UTF-8".to_string()], - timezone_id: "America/Los_Angeles".to_string(), - supported_locales: vec!["en_US.UTF-8".to_string()], - ui_locale: "en".to_string(), - keymap: "us".parse().unwrap(), - keymaps: vec![], - } + pub fn translate(&mut self, locale: &str) -> Result<(), Error> { + self.ui_locale = locale.to_string(); + let language = locale.split_once("_").map(|(l, _)| l).unwrap_or("en"); + self.timezones_db.read(&language)?; + self.locales_db.read(&language)?; + self.ui_locale = locale.to_string(); + Ok(()) } } @@ -242,7 +213,7 @@ pub async fn export_dbus_objects( const PATH: &str = "/org/opensuse/Agama1/Locale"; // When serving, request the service name _after_ exposing the main object - let locale = Locale::from_system()?; + let locale = Locale::new_with_locale("en")?; connection.object_server().at(PATH, locale).await?; Ok(()) diff --git a/rust/agama-dbus-server/src/locale/locale.rs b/rust/agama-dbus-server/src/locale/locale.rs new file mode 100644 index 0000000000..4c85c43151 --- /dev/null +++ b/rust/agama-dbus-server/src/locale/locale.rs @@ -0,0 +1,136 @@ +//! This module provides support for reading the locales database. + +use crate::error::Error; +use agama_locale_data::{InvalidLocaleCode, LocaleCode}; +use anyhow::Context; +use std::process::Command; + +/// Represents a locale, including the localized language and territory. +#[derive(Debug)] +pub struct LocaleEntry { + /// The locale code (e.g., "es_ES.UTF-8"). + pub code: LocaleCode, + /// Localized language name (e.g., "Spanish", "Español", etc.) + pub language: String, + /// Localized territory name (e.g., "Spain", "España", etc.) + pub territory: String, +} + +/// Represents the locales database. +/// +/// The list of supported locales is read from `systemd-localed`. However, the +/// translations are obtained from the `agama_locale_data` crate. +#[derive(Default)] +pub struct LocalesDatabase { + known_locales: Vec, + locales: Vec, +} + +impl LocalesDatabase { + pub fn new() -> Self { + Self::default() + } + + /// Loads the list of locales. + /// + /// * `locale`: locale to translate the descriptions. + pub fn read(&mut self, ui_language: &str) -> Result<(), Error> { + let result = Command::new("/usr/bin/localectl") + .args(["list-locales"]) + .output() + .context("Failed to get the list of locales")?; + let output = + String::from_utf8(result.stdout).context("Invalid UTF-8 sequence from list-locales")?; + self.known_locales = output + .lines() + .filter_map(|line| TryInto::::try_into(line).ok()) + .collect(); + self.locales = self.get_locales(&ui_language)?; + Ok(()) + } + + /// Determines whether a locale exists in the database. + pub fn exists(&self, locale: T) -> bool + where + T: TryInto, + T::Error: Into, + { + if let Ok(locale) = TryInto::::try_into(locale) { + return self.known_locales.contains(&locale); + } + + false + } + + pub fn entries(&self) -> &Vec { + &self.locales + } + + /// Gets the supported locales information. + /// + /// * `language`: language to use in the translations. + fn get_locales(&self, ui_language: &str) -> Result, Error> { + const DEFAULT_LANG: &str = "en"; + let mut result = Vec::with_capacity(self.known_locales.len()); + let languages = agama_locale_data::get_languages()?; + let territories = agama_locale_data::get_territories()?; + for code in self.known_locales.as_slice() { + let language = languages + .find_by_id(&code.language) + .context("language not found")?; + + let names = &language.names; + let language_label = names + .name_for(&ui_language) + .or_else(|| names.name_for(DEFAULT_LANG)) + .unwrap_or(language.id.to_string()); + + let territory = territories + .find_by_id(&code.territory) + .context("territory not found")?; + + let names = &territory.names; + let territory_label = names + .name_for(&ui_language) + .or_else(|| names.name_for(DEFAULT_LANG)) + .unwrap_or(territory.id.to_string()); + + let entry = LocaleEntry { + code: code.clone(), + language: language_label, + territory: territory_label, + }; + result.push(entry) + } + + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::LocalesDatabase; + use agama_locale_data::LocaleCode; + + #[test] + fn test_read_locales() { + let mut db = LocalesDatabase::new(); + db.read("de").unwrap(); + let english: LocaleCode = "es_ES".try_into().unwrap(); + let found_locales = db.entries(); + let found = found_locales + .into_iter() + .find(|l| l.code == english) + .unwrap(); + assert_eq!(&found.language, "Spanisch"); + assert_eq!(&found.territory, "Spanien"); + } + + #[test] + fn test_locale_exists() { + let mut db = LocalesDatabase::new(); + db.read("en").unwrap(); + assert!(db.exists("en_US")); + assert!(!db.exists("unknown_UNKNOWN")); + } +} diff --git a/rust/agama-dbus-server/src/locale/timezone.rs b/rust/agama-dbus-server/src/locale/timezone.rs new file mode 100644 index 0000000000..61af050a6f --- /dev/null +++ b/rust/agama-dbus-server/src/locale/timezone.rs @@ -0,0 +1,99 @@ +//! This module provides support for reading the timezones database. + +use crate::error::Error; +use agama_locale_data::timezone_part::TimezoneIdParts; + +/// Represents a timezone, including each part as localized. +#[derive(Debug)] +pub struct TimezoneEntry { + /// Timezone identifier (e.g. "Atlantic/Canary"). + pub code: String, + /// Localized parts (e.g., "Atlántico", "Canarias"). + pub parts: Vec, +} + +#[derive(Default)] +pub struct TimezonesDatabase { + timezones: Vec, +} + +impl TimezonesDatabase { + pub fn new() -> Self { + Self::default() + } + + /// Initializes the list of known timezones. + pub fn read(&mut self, ui_language: &str) -> Result<(), Error> { + self.timezones = self.get_timezones(ui_language)?; + Ok(()) + } + + /// Determines whether a timezone exists in the database. + pub fn exists(&self, timezone: &String) -> bool { + self.timezones.iter().any(|t| &t.code == timezone) + } + + pub fn entries(&self) -> &Vec { + &self.timezones + } + + /// Returns a list of the supported timezones. + /// + /// Each element of the list contains a timezone identifier and a vector + /// containing the translation of each part of the language. + /// + /// * `locale`: locale to use in the translations. + fn get_timezones(&self, locale: &str) -> Result, Error> { + let timezones = agama_locale_data::get_timezones(); + let tz_parts = agama_locale_data::get_timezone_parts()?; + let ret = timezones + .into_iter() + .map(|tz| { + let parts = translate_parts(&tz, &locale, &tz_parts); + TimezoneEntry { code: tz, parts } + }) + .collect(); + Ok(ret) + } +} + +fn translate_parts(timezone: &str, locale: &str, tz_parts: &TimezoneIdParts) -> Vec { + timezone + .split("/") + .map(|part| { + tz_parts + .localize_part(part, &locale) + .unwrap_or(part.to_owned()) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::TimezonesDatabase; + + #[test] + fn test_read_timezones() { + let mut db = TimezonesDatabase::new(); + db.read("es").unwrap(); + let found_timezones = db.entries(); + dbg!(&found_timezones); + let found = found_timezones + .into_iter() + .find(|tz| tz.code == "Europe/Berlin") + .unwrap(); + assert_eq!(&found.code, "Europe/Berlin"); + assert_eq!( + found.parts, + vec!["Europa".to_string(), "Berlín".to_string()] + ) + } + + #[test] + fn test_timezone_exists() { + let mut db = TimezonesDatabase::new(); + db.read("es").unwrap(); + assert!(db.exists(&"Atlantic/Canary".to_string())); + assert!(!db.exists(&"Unknown/Unknown".to_string())); + } +} diff --git a/rust/agama-locale-data/src/locale.rs b/rust/agama-locale-data/src/locale.rs index e8f4c4882b..d8fc372747 100644 --- a/rust/agama-locale-data/src/locale.rs +++ b/rust/agama-locale-data/src/locale.rs @@ -5,6 +5,7 @@ use std::sync::OnceLock; use std::{fmt::Display, str::FromStr}; use thiserror::Error; +#[derive(Clone, Debug, PartialEq)] pub struct LocaleCode { // ISO-639 pub language: String, @@ -13,6 +14,12 @@ pub struct LocaleCode { // encoding: String, } +impl Display for LocaleCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}_{}", &self.language, &self.territory) + } +} + #[derive(Error, Debug)] #[error("Not a valid locale string: {0}")] pub struct InvalidLocaleCode(String); From e48fc869d22346571e151bf8e75f861953fe07fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 28 Nov 2023 22:02:09 +0000 Subject: [PATCH 42/58] Fix documentation --- rust/agama-dbus-server/src/locale/locale.rs | 2 +- rust/agama-dbus-server/src/locale/timezone.rs | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/rust/agama-dbus-server/src/locale/locale.rs b/rust/agama-dbus-server/src/locale/locale.rs index 4c85c43151..c6c446dd99 100644 --- a/rust/agama-dbus-server/src/locale/locale.rs +++ b/rust/agama-dbus-server/src/locale/locale.rs @@ -33,7 +33,7 @@ impl LocalesDatabase { /// Loads the list of locales. /// - /// * `locale`: locale to translate the descriptions. + /// * `ui_language`: language to translate the descriptions (e.g., "en"). pub fn read(&mut self, ui_language: &str) -> Result<(), Error> { let result = Command::new("/usr/bin/localectl") .args(["list-locales"]) diff --git a/rust/agama-dbus-server/src/locale/timezone.rs b/rust/agama-dbus-server/src/locale/timezone.rs index 61af050a6f..74c9aa6671 100644 --- a/rust/agama-dbus-server/src/locale/timezone.rs +++ b/rust/agama-dbus-server/src/locale/timezone.rs @@ -23,6 +23,8 @@ impl TimezonesDatabase { } /// Initializes the list of known timezones. + /// + /// * `ui_language`: language to translate the descriptions (e.g., "en"). pub fn read(&mut self, ui_language: &str) -> Result<(), Error> { self.timezones = self.get_timezones(ui_language)?; Ok(()) @@ -42,14 +44,14 @@ impl TimezonesDatabase { /// Each element of the list contains a timezone identifier and a vector /// containing the translation of each part of the language. /// - /// * `locale`: locale to use in the translations. - fn get_timezones(&self, locale: &str) -> Result, Error> { + /// * `ui_language`: language to translate the descriptions (e.g., "en"). + fn get_timezones(&self, ui_language: &str) -> Result, Error> { let timezones = agama_locale_data::get_timezones(); let tz_parts = agama_locale_data::get_timezone_parts()?; let ret = timezones .into_iter() .map(|tz| { - let parts = translate_parts(&tz, &locale, &tz_parts); + let parts = translate_parts(&tz, &ui_language, &tz_parts); TimezoneEntry { code: tz, parts } }) .collect(); @@ -57,12 +59,12 @@ impl TimezonesDatabase { } } -fn translate_parts(timezone: &str, locale: &str, tz_parts: &TimezoneIdParts) -> Vec { +fn translate_parts(timezone: &str, ui_language: &str, tz_parts: &TimezoneIdParts) -> Vec { timezone .split("/") .map(|part| { tz_parts - .localize_part(part, &locale) + .localize_part(part, &ui_language) .unwrap_or(part.to_owned()) }) .collect() From 879ae1302b9f673448f4ff045df4a0e92664f5d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 28 Nov 2023 22:41:17 +0000 Subject: [PATCH 43/58] Disable localectl tests --- rust/agama-dbus-server/src/locale/locale.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rust/agama-dbus-server/src/locale/locale.rs b/rust/agama-dbus-server/src/locale/locale.rs index c6c446dd99..fb64845ffa 100644 --- a/rust/agama-dbus-server/src/locale/locale.rs +++ b/rust/agama-dbus-server/src/locale/locale.rs @@ -112,20 +112,22 @@ mod tests { use super::LocalesDatabase; use agama_locale_data::LocaleCode; + #[ignore] #[test] fn test_read_locales() { let mut db = LocalesDatabase::new(); db.read("de").unwrap(); - let english: LocaleCode = "es_ES".try_into().unwrap(); let found_locales = db.entries(); + let spanish: LocaleCode = "es_ES".try_into().unwrap(); let found = found_locales .into_iter() - .find(|l| l.code == english) + .find(|l| l.code == spanish) .unwrap(); assert_eq!(&found.language, "Spanisch"); assert_eq!(&found.territory, "Spanien"); } + #[ignore] #[test] fn test_locale_exists() { let mut db = LocalesDatabase::new(); From be2cabb0d49b0ca4d95c49a0e55faa100b83e512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 29 Nov 2023 07:05:11 +0000 Subject: [PATCH 44/58] Read the list of keymaps once --- rust/agama-dbus-server/src/locale.rs | 14 +++++--- rust/agama-dbus-server/src/locale/keyboard.rs | 33 ++++++++++++++++++- rust/agama-dbus-server/src/locale/locale.rs | 1 + rust/agama-dbus-server/src/locale/timezone.rs | 1 + 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index d94b102ba9..01e734dbe6 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -6,7 +6,7 @@ mod timezone; use crate::error::Error; use agama_locale_data::KeymapId; use anyhow::Context; -use keyboard::{get_keymaps, Keymap}; +use keyboard::KeymapsDatabase; use locale::LocalesDatabase; use std::process::Command; use timezone::TimezonesDatabase; @@ -18,7 +18,7 @@ pub struct Locale { locales: Vec, locales_db: LocalesDatabase, keymap: KeymapId, - keymaps: Vec, + keymaps_db: KeymapsDatabase, ui_locale: String, } @@ -88,7 +88,8 @@ impl Locale { /// * The name of the keyboard in language set by the UILocale property. fn list_keymaps(&self) -> Result, Error> { let keymaps = self - .keymaps + .keymaps_db + .entries() .iter() .map(|k| (k.id.to_string(), k.localized_description())) .collect(); @@ -106,7 +107,7 @@ impl Locale { .parse() .map_err(|_e| zbus::fdo::Error::InvalidArgs("Invalid keymap".to_string()))?; - if !self.keymaps.iter().any(|k| k.id == keymap_id) { + if !self.keymaps_db.exists(&keymap_id) { return Err(zbus::fdo::Error::Failed( "Invalid keyboard value".to_string(), )); @@ -184,13 +185,16 @@ impl Locale { timezones_db.read(&locale)?; let default_timezone = timezones_db.entries().get(0).unwrap(); + let mut keymaps_db = KeymapsDatabase::new(); + keymaps_db.read()?; + let locale = Self { keymap: "us".parse().unwrap(), timezone: default_timezone.code.to_string(), locales: vec![default_locale.code.to_string()], locales_db, timezones_db, - keymaps: get_keymaps()?, + keymaps_db, ui_locale: locale.to_string(), }; diff --git a/rust/agama-dbus-server/src/locale/keyboard.rs b/rust/agama-dbus-server/src/locale/keyboard.rs index 449b632f87..8a286bf4f6 100644 --- a/rust/agama-dbus-server/src/locale/keyboard.rs +++ b/rust/agama-dbus-server/src/locale/keyboard.rs @@ -21,11 +21,42 @@ impl Keymap { } } +/// Represents the keymaps database. +/// +/// The list of supported keymaps is read from `systemd-localed` and the +/// descriptions from the X Keyboard Configuraiton Database (see +/// `agama_locale_data::XkbConfigRegistry`). +#[derive(Default)] +pub struct KeymapsDatabase { + keymaps: Vec, +} + +impl KeymapsDatabase { + pub fn new() -> Self { + Self::default() + } + + /// Reads the list of keymaps. + pub fn read(&mut self) -> anyhow::Result<()> { + self.keymaps = get_keymaps()?; + Ok(()) + } + + pub fn exists(&self, id: &KeymapId) -> bool { + self.keymaps.iter().any(|k| &k.id == id) + } + + /// Returns the list of keymaps. + pub fn entries(&self) -> &Vec { + &self.keymaps + } +} + /// Returns the list of keymaps to offer. /// /// It only includes the keyboards supported by `localectl` but getting /// the description from the X Keyboard Configuration Database. -pub fn get_keymaps() -> anyhow::Result> { +fn get_keymaps() -> anyhow::Result> { let mut keymaps: Vec = vec![]; let xkb_descriptions = get_keymap_descriptions(); let keymap_ids = get_localectl_keymaps()?; diff --git a/rust/agama-dbus-server/src/locale/locale.rs b/rust/agama-dbus-server/src/locale/locale.rs index fb64845ffa..3dbeb0292e 100644 --- a/rust/agama-dbus-server/src/locale/locale.rs +++ b/rust/agama-dbus-server/src/locale/locale.rs @@ -62,6 +62,7 @@ impl LocalesDatabase { false } + /// Returns the list of locales. pub fn entries(&self) -> &Vec { &self.locales } diff --git a/rust/agama-dbus-server/src/locale/timezone.rs b/rust/agama-dbus-server/src/locale/timezone.rs index 74c9aa6671..8b60197a45 100644 --- a/rust/agama-dbus-server/src/locale/timezone.rs +++ b/rust/agama-dbus-server/src/locale/timezone.rs @@ -35,6 +35,7 @@ impl TimezonesDatabase { self.timezones.iter().any(|t| &t.code == timezone) } + /// Returns the list of timezones. pub fn entries(&self) -> &Vec { &self.timezones } From 64c1056af13cac752560fedf02e079b849660fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 29 Nov 2023 07:19:35 +0000 Subject: [PATCH 45/58] Update Locale D-Bus documentation --- .../bus/org.opensuse.Agama1.Locale.bus.xml | 48 +++++++++------ doc/dbus/org.opensuse.Agama1.Locale.doc.xml | 61 ++++++++++--------- 2 files changed, 59 insertions(+), 50 deletions(-) diff --git a/doc/dbus/bus/org.opensuse.Agama1.Locale.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.Locale.bus.xml index 38416bd6bc..850570b618 100644 --- a/doc/dbus/bus/org.opensuse.Agama1.Locale.bus.xml +++ b/doc/dbus/bus/org.opensuse.Agama1.Locale.bus.xml @@ -38,39 +38,47 @@ - - + + - - - - - + + + - - + + - - diff --git a/doc/dbus/org.opensuse.Agama1.Locale.doc.xml b/doc/dbus/org.opensuse.Agama1.Locale.doc.xml index 8844528be1..03120855a6 100644 --- a/doc/dbus/org.opensuse.Agama1.Locale.doc.xml +++ b/doc/dbus/org.opensuse.Agama1.Locale.doc.xml @@ -1,47 +1,48 @@ - - + - - - + + - - - - - + + + - - + + - - - - From 69a9ad4edbd64b724bf7ef1cdcc32dc82e555650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 29 Nov 2023 11:30:18 +0000 Subject: [PATCH 46/58] Update services changes files --- rust/package/agama-cli.changes | 16 ++++++++++++++++ service/package/rubygem-agama.changes | 6 ++++++ 2 files changed, 22 insertions(+) diff --git a/rust/package/agama-cli.changes b/rust/package/agama-cli.changes index 0e95ea5b8c..59322c91b8 100644 --- a/rust/package/agama-cli.changes +++ b/rust/package/agama-cli.changes @@ -1,3 +1,19 @@ +------------------------------------------------------------------- +Wed Nov 29 11:19:51 UTC 2023 - Imobach Gonzalez Sosa + +- Rework the org.opensuse.Agama1.Locale interface + (gh#openSUSE/agama#881): + * Replace LabelsForLocales function with ListLocales. + * Add a ListKeymaps function. + * Extend the ListTimezone function to include the translation of + each part. + * Drop ListUILocales and ListVConsoleKeyboards functions. + * Remove the and SupportedLocales property. + * Do not read the lists of locales, keymaps and timezones on + each request. + * Peform some validation when trying to change the Locales, + Keymap and Timezone properties. + ------------------------------------------------------------------- Thu Nov 16 11:06:30 UTC 2023 - Imobach Gonzalez Sosa diff --git a/service/package/rubygem-agama.changes b/service/package/rubygem-agama.changes index 6764c40c9c..9c3defb84d 100644 --- a/service/package/rubygem-agama.changes +++ b/service/package/rubygem-agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Nov 29 11:26:39 UTC 2023 - Imobach Gonzalez Sosa + +- Update the software proposal when the locale changes + (gh#openSUSE/agama#881). + ------------------------------------------------------------------- Fri Nov 24 14:50:22 UTC 2023 - Imobach Gonzalez Sosa From 8b4820c07c25468925d2cecb3280f845db42ff37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 29 Nov 2023 12:07:12 +0000 Subject: [PATCH 47/58] [web] Changelog --- web/package/cockpit-agama.changes | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/package/cockpit-agama.changes b/web/package/cockpit-agama.changes index 657c81de65..bc67718412 100644 --- a/web/package/cockpit-agama.changes +++ b/web/package/cockpit-agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Nov 29 12:06:07 UTC 2023 - José Iván López González + +- Allow selecting language, keymap and timezone for the target + system (gh#openSUSE/agama#881). + ------------------------------------------------------------------- Tue Nov 21 15:21:06 UTC 2023 - David Diaz From e80fa6c91362c623fc2b22a9ee6dd8902e5aacd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 29 Nov 2023 12:23:16 +0000 Subject: [PATCH 48/58] Fix CLI changes list --- rust/package/agama-cli.changes | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/package/agama-cli.changes b/rust/package/agama-cli.changes index 59322c91b8..cf5147ee17 100644 --- a/rust/package/agama-cli.changes +++ b/rust/package/agama-cli.changes @@ -8,7 +8,7 @@ Wed Nov 29 11:19:51 UTC 2023 - Imobach Gonzalez Sosa * Extend the ListTimezone function to include the translation of each part. * Drop ListUILocales and ListVConsoleKeyboards functions. - * Remove the and SupportedLocales property. + * Remove the SupportedLocales and VConsoleKeyboard properties. * Do not read the lists of locales, keymaps and timezones on each request. * Peform some validation when trying to change the Locales, From 79dd6978d472fef9bf2f8050e09e0295bd0b65a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 30 Nov 2023 10:27:27 +0000 Subject: [PATCH 49/58] Set the initial locale to LANG --- rust/agama-dbus-server/src/locale.rs | 6 +++--- rust/agama-dbus-server/src/locale/helpers.rs | 7 +++++-- rust/agama-dbus-server/src/main.rs | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index 01e734dbe6..899db6ba7f 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -212,13 +212,13 @@ impl Locale { } pub async fn export_dbus_objects( - connection: &Connection, + connection: &Connection, locale: &str ) -> Result<(), Box> { const PATH: &str = "/org/opensuse/Agama1/Locale"; // When serving, request the service name _after_ exposing the main object - let locale = Locale::new_with_locale("en")?; - connection.object_server().at(PATH, locale).await?; + let locale_iface = Locale::new_with_locale(locale)?; + connection.object_server().at(PATH, locale_iface).await?; Ok(()) } diff --git a/rust/agama-dbus-server/src/locale/helpers.rs b/rust/agama-dbus-server/src/locale/helpers.rs index 8f7d096d0c..87e62f9eb9 100644 --- a/rust/agama-dbus-server/src/locale/helpers.rs +++ b/rust/agama-dbus-server/src/locale/helpers.rs @@ -5,12 +5,15 @@ use gettextrs::{bind_textdomain_codeset, setlocale, textdomain, LocaleCategory}; use std::env; -pub fn init_locale() -> Result<(), Box> { +/// Initializes the service locale. +/// +/// It returns the used locale. Defaults to `en_US.UTF-8`. +pub fn init_locale() -> Result> { let locale = env::var("LANG").unwrap_or("en_US.UTF-8".to_owned()); set_service_locale(&locale); textdomain("xkeyboard-config")?; bind_textdomain_codeset("xkeyboard-config", "UTF-8")?; - Ok(()) + Ok(locale) } pub fn set_service_locale(locale: &str) { diff --git a/rust/agama-dbus-server/src/main.rs b/rust/agama-dbus-server/src/main.rs index 7652c1fc82..33c0c41db8 100644 --- a/rust/agama-dbus-server/src/main.rs +++ b/rust/agama-dbus-server/src/main.rs @@ -11,7 +11,7 @@ const SERVICE_NAME: &str = "org.opensuse.Agama1"; #[tokio::main] async fn main() -> Result<(), Box> { - helpers::init_locale()?; + let locale = helpers::init_locale()?; // be smart with logging and log directly to journal if connected to it if systemd_journal_logger::connected_to_journal() { @@ -37,7 +37,7 @@ async fn main() -> Result<(), Box> { // When adding more services here, the order might be important. questions::export_dbus_objects(&connection).await?; log::info!("Started questions interface"); - locale::export_dbus_objects(&connection).await?; + locale::export_dbus_objects(&connection, &locale).await?; log::info!("Started locale interface"); network::export_dbus_objects(&connection).await?; log::info!("Started network interface"); From ece8bca5faa65e7676d3bddce92be0dde9336a9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 30 Nov 2023 10:39:02 +0000 Subject: [PATCH 50/58] Update the documentation of the Locale interface --- doc/dbus/bus/org.opensuse.Agama1.Locale.bus.xml | 8 ++++---- doc/dbus/org.opensuse.Agama1.Locale.doc.xml | 8 ++++---- rust/agama-dbus-server/src/locale.rs | 10 ++++------ rust/agama-dbus-server/src/locale/locale.rs | 2 +- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/doc/dbus/bus/org.opensuse.Agama1.Locale.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.Locale.bus.xml index 850570b618..8f4dec9054 100644 --- a/doc/dbus/bus/org.opensuse.Agama1.Locale.bus.xml +++ b/doc/dbus/bus/org.opensuse.Agama1.Locale.bus.xml @@ -43,10 +43,10 @@ Each element of the list has these parts: * The locale code (e.g., "es_ES.UTF-8"). - * A pair composed by the language and the territory names in english - (e.g. ("Spanish", "Spain")). - * A pair composed by the language and the territory names in its own language - (e.g. ("Español", "España")). + * The name of the language according to the language defined by the + UILocale property. + * The name of the territory according to the language defined by the + UILocale property. --> diff --git a/doc/dbus/org.opensuse.Agama1.Locale.doc.xml b/doc/dbus/org.opensuse.Agama1.Locale.doc.xml index 03120855a6..3f0256a75e 100644 --- a/doc/dbus/org.opensuse.Agama1.Locale.doc.xml +++ b/doc/dbus/org.opensuse.Agama1.Locale.doc.xml @@ -7,10 +7,10 @@ Each element of the list has these parts: * The locale code (e.g., "es_ES.UTF-8"). - * A pair composed by the language and the territory names in english - (e.g. ("Spanish", "Spain")). - * A pair composed by the language and the territory names in its own language - (e.g. ("Español", "España")). + * The name of the language according to the language defined by the + UILocale property. + * The name of the territory according to the language defined by the + UILocale property. --> diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index 899db6ba7f..ee1baa7357 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -29,12 +29,10 @@ impl Locale { /// Each element of the list has these parts: /// /// * The locale code (e.g., "es_ES.UTF-8"). - /// * A pair composed by the language and the territory names in english - /// (e.g. ("Spanish", "Spain")). - /// * A pair composed by the language and the territory names in its own language - /// (e.g. ("Español", "España")). - /// - // NOTE: check how often it is used and if often, it can be easily cached + /// * The name of the language according to the language defined by the + /// UILocale property. + /// * The name of the territory according to the language defined by the + /// UILocale property. fn list_locales(&self) -> Result, Error> { let locales = self .locales_db diff --git a/rust/agama-dbus-server/src/locale/locale.rs b/rust/agama-dbus-server/src/locale/locale.rs index 3dbeb0292e..88a6b4c95e 100644 --- a/rust/agama-dbus-server/src/locale/locale.rs +++ b/rust/agama-dbus-server/src/locale/locale.rs @@ -69,7 +69,7 @@ impl LocalesDatabase { /// Gets the supported locales information. /// - /// * `language`: language to use in the translations. + /// * `ui_language`: language to use in the translations. fn get_locales(&self, ui_language: &str) -> Result, Error> { const DEFAULT_LANG: &str = "en"; let mut result = Vec::with_capacity(self.known_locales.len()); From 1872785da77a470d2a9df483909fe16c10b6a384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 30 Nov 2023 10:39:25 +0000 Subject: [PATCH 51/58] Fix error description --- rust/agama-dbus-server/src/locale.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index ee1baa7357..104aa764cc 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -107,7 +107,7 @@ impl Locale { if !self.keymaps_db.exists(&keymap_id) { return Err(zbus::fdo::Error::Failed( - "Invalid keyboard value".to_string(), + "Invalid keymap value".to_string(), )); } self.keymap = keymap_id; From 0e6b798d893113455c9a706a7eef6b6d8f72dc7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 30 Nov 2023 11:46:16 +0000 Subject: [PATCH 52/58] Handle better the UILocale property --- rust/agama-dbus-server/src/locale.rs | 33 +++++++++++--------- rust/agama-dbus-server/src/locale/helpers.rs | 13 ++++++-- rust/agama-locale-data/src/locale.rs | 9 ++++++ 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index 104aa764cc..a4f1830d86 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -4,7 +4,7 @@ mod locale; mod timezone; use crate::error::Error; -use agama_locale_data::KeymapId; +use agama_locale_data::{KeymapId, LocaleCode}; use anyhow::Context; use keyboard::KeymapsDatabase; use locale::LocalesDatabase; @@ -19,7 +19,7 @@ pub struct Locale { locales_db: LocalesDatabase, keymap: KeymapId, keymaps_db: KeymapsDatabase, - ui_locale: String, + ui_locale: LocaleCode, } #[dbus_interface(name = "org.opensuse.Agama1.Locale")] @@ -68,14 +68,17 @@ impl Locale { } #[dbus_interface(property, name = "UILocale")] - fn ui_locale(&self) -> &str { - &self.ui_locale + fn ui_locale(&self) -> String { + self.ui_locale.to_string() } #[dbus_interface(property, name = "UILocale")] fn set_ui_locale(&mut self, locale: &str) -> zbus::fdo::Result<()> { - helpers::set_service_locale(locale); - Ok(self.translate(locale)?) + let locale: LocaleCode = locale + .try_into() + .map_err(|_e| zbus::fdo::Error::Failed(format!("Invalid locale value '{locale}'")))?; + helpers::set_service_locale(&locale); + Ok(self.translate(&locale)?) } /// Returns a list of the supported keymaps. @@ -174,7 +177,8 @@ impl Locale { } impl Locale { - pub fn new_with_locale(locale: &str) -> Result { + pub fn new_with_locale(ui_locale: &LocaleCode) -> Result { + let locale = ui_locale.to_string(); let mut locales_db = LocalesDatabase::new(); locales_db.read(&locale)?; let default_locale = locales_db.entries().get(0).unwrap(); @@ -193,24 +197,23 @@ impl Locale { locales_db, timezones_db, keymaps_db, - ui_locale: locale.to_string(), + ui_locale: ui_locale.clone(), }; Ok(locale) } - pub fn translate(&mut self, locale: &str) -> Result<(), Error> { - self.ui_locale = locale.to_string(); - let language = locale.split_once("_").map(|(l, _)| l).unwrap_or("en"); - self.timezones_db.read(&language)?; - self.locales_db.read(&language)?; - self.ui_locale = locale.to_string(); + pub fn translate(&mut self, locale: &LocaleCode) -> Result<(), Error> { + self.timezones_db.read(&locale.language)?; + self.locales_db.read(&locale.language)?; + self.ui_locale = locale.clone(); Ok(()) } } pub async fn export_dbus_objects( - connection: &Connection, locale: &str + connection: &Connection, + locale: &LocaleCode, ) -> Result<(), Box> { const PATH: &str = "/org/opensuse/Agama1/Locale"; diff --git a/rust/agama-dbus-server/src/locale/helpers.rs b/rust/agama-dbus-server/src/locale/helpers.rs index 87e62f9eb9..d9e65b6de7 100644 --- a/rust/agama-dbus-server/src/locale/helpers.rs +++ b/rust/agama-dbus-server/src/locale/helpers.rs @@ -2,21 +2,28 @@ //! //! FIXME: find a better place for the localization function +use agama_locale_data::LocaleCode; use gettextrs::{bind_textdomain_codeset, setlocale, textdomain, LocaleCategory}; use std::env; /// Initializes the service locale. /// /// It returns the used locale. Defaults to `en_US.UTF-8`. -pub fn init_locale() -> Result> { - let locale = env::var("LANG").unwrap_or("en_US.UTF-8".to_owned()); +pub fn init_locale() -> Result> { + let lang = env::var("LANG").unwrap_or("en_US.UTF-8".to_string()); + let locale: LocaleCode = lang.as_str().try_into().unwrap_or_default(); + set_service_locale(&locale); textdomain("xkeyboard-config")?; bind_textdomain_codeset("xkeyboard-config", "UTF-8")?; Ok(locale) } -pub fn set_service_locale(locale: &str) { +/// Sets the service locale. +/// +pub fn set_service_locale(locale: &LocaleCode) { + // Let's force the encoding to be 'UTF-8'. + let locale = format!("{}.UTF-8", locale.to_string()); if setlocale(LocaleCategory::LcAll, locale).is_none() { log::warn!("Could not set the locale"); } diff --git a/rust/agama-locale-data/src/locale.rs b/rust/agama-locale-data/src/locale.rs index d8fc372747..90551ce233 100644 --- a/rust/agama-locale-data/src/locale.rs +++ b/rust/agama-locale-data/src/locale.rs @@ -20,6 +20,15 @@ impl Display for LocaleCode { } } +impl Default for LocaleCode { + fn default() -> Self { + Self { + language: "en".to_string(), + territory: "US".to_string(), + } + } +} + #[derive(Error, Debug)] #[error("Not a valid locale string: {0}")] pub struct InvalidLocaleCode(String); From ca644a13976cdda8b0574ba0e86c801c506a16b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 30 Nov 2023 11:47:02 +0000 Subject: [PATCH 53/58] Make rustfmt happy --- rust/agama-dbus-server/src/locale.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index a4f1830d86..12545467e0 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -109,9 +109,7 @@ impl Locale { .map_err(|_e| zbus::fdo::Error::InvalidArgs("Invalid keymap".to_string()))?; if !self.keymaps_db.exists(&keymap_id) { - return Err(zbus::fdo::Error::Failed( - "Invalid keymap value".to_string(), - )); + return Err(zbus::fdo::Error::Failed("Invalid keymap value".to_string())); } self.keymap = keymap_id; Ok(()) From 6119c0bc6ba5d22c849f6151700d49be298f3b14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 30 Nov 2023 11:58:52 +0000 Subject: [PATCH 54/58] Set the default language to the system's one --- rust/agama-dbus-server/src/locale.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs index 12545467e0..7edaaed68a 100644 --- a/rust/agama-dbus-server/src/locale.rs +++ b/rust/agama-dbus-server/src/locale.rs @@ -179,7 +179,13 @@ impl Locale { let locale = ui_locale.to_string(); let mut locales_db = LocalesDatabase::new(); locales_db.read(&locale)?; - let default_locale = locales_db.entries().get(0).unwrap(); + + let default_locale = if locales_db.exists(locale.as_str()) { + ui_locale.to_string() + } else { + // TODO: handle the case where the database is empty (not expected!) + locales_db.entries().get(0).unwrap().code.to_string() + }; let mut timezones_db = TimezonesDatabase::new(); timezones_db.read(&locale)?; @@ -191,7 +197,7 @@ impl Locale { let locale = Self { keymap: "us".parse().unwrap(), timezone: default_timezone.code.to_string(), - locales: vec![default_locale.code.to_string()], + locales: vec![default_locale], locales_db, timezones_db, keymaps_db, From 98d4116f7c4a66d9e2dc243cf33b918ea74c825a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 30 Nov 2023 13:17:22 +0000 Subject: [PATCH 55/58] [web] Add placeholder prop --- web/src/components/core/ListSearch.jsx | 9 +++++++-- web/src/components/l10n/KeymapSelector.jsx | 5 ++++- web/src/components/l10n/LocaleSelector.jsx | 4 +++- web/src/components/l10n/TimezoneSelector.jsx | 5 ++++- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/web/src/components/core/ListSearch.jsx b/web/src/components/core/ListSearch.jsx index c859b37aa7..092c30ef73 100644 --- a/web/src/components/core/ListSearch.jsx +++ b/web/src/components/core/ListSearch.jsx @@ -40,15 +40,20 @@ const search = (elements, term) => { * @component * * @param {object} props + * @param {string} [props.placeholder] * @param {object[]} [props.elements] - List of element in which to search. * @param {(elements: object[]) => void} - Callback to be called with the filtered list of elements. */ -export default function ListSearch({ elements = [], onChange: onChangeProp = noop }) { +export default function ListSearch({ + placeholder = _("Search"), + elements = [], + onChange: onChangeProp = noop +}) { const searchHandler = useDebounce(term => onChangeProp(search(elements, term)), 500); const onChange = (e) => searchHandler(e.target.value); return ( - + ); } diff --git a/web/src/components/l10n/KeymapSelector.jsx b/web/src/components/l10n/KeymapSelector.jsx index c94f0ed0ba..505627ac39 100644 --- a/web/src/components/l10n/KeymapSelector.jsx +++ b/web/src/components/l10n/KeymapSelector.jsx @@ -74,10 +74,13 @@ const KeymapItem = ({ keymap }) => { export default function KeymapSelector({ value, keymaps = [], onChange = noop }) { const [filteredKeymaps, setFilteredKeymaps] = useState(keymaps); + // TRANSLATORS: placeholder text for search input in the keyboard selector. + const helpSearch = _("Filter by description or keymap code"); + return ( <>
    - +
    { filteredKeymaps.map((keymap, index) => ( diff --git a/web/src/components/l10n/LocaleSelector.jsx b/web/src/components/l10n/LocaleSelector.jsx index 34b56cf65a..1e55d49d72 100644 --- a/web/src/components/l10n/LocaleSelector.jsx +++ b/web/src/components/l10n/LocaleSelector.jsx @@ -75,10 +75,12 @@ const LocaleItem = ({ locale }) => { export default function LocaleSelector({ value, locales = [], onChange = noop }) { const [filteredLocales, setFilteredLocales] = useState(locales); + const searchHelp = _("Filter by language, territory or locale code"); + return ( <>
    - +
    { filteredLocales.map((locale, index) => ( diff --git a/web/src/components/l10n/TimezoneSelector.jsx b/web/src/components/l10n/TimezoneSelector.jsx index 51343525b9..e9baacf205 100644 --- a/web/src/components/l10n/TimezoneSelector.jsx +++ b/web/src/components/l10n/TimezoneSelector.jsx @@ -94,10 +94,13 @@ export default function TimezoneSelector({ value, timezones = [], onChange = noo const [filteredTimezones, setFilteredTimezones] = useState(displayTimezones); const date = new Date(); + // TRANSLATORS: placeholder text for search input in the timezone selector. + const helpSearch = _("Filter by territory, time zone code or UTC offset"); + return ( <>
    - +
    { filteredTimezones.map((timezone, index) => ( From 8c7edf3fc8c401bbb4ef0e7fad4abea3b5e2b0fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 30 Nov 2023 13:21:25 +0000 Subject: [PATCH 56/58] [web] Add missing test --- web/src/components/core/ListSearch.test.jsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/web/src/components/core/ListSearch.test.jsx b/web/src/components/core/ListSearch.test.jsx index 9e6659a2e5..b431f69597 100644 --- a/web/src/components/core/ListSearch.test.jsx +++ b/web/src/components/core/ListSearch.test.jsx @@ -70,6 +70,18 @@ it("searches for elements matching the given text", async () => { screen.getByRole("option", { name: /grape/ }); screen.getByRole("option", { name: /pear/ }); + // Search for known fruit + await user.clear(searchInput); + await user.type(searchInput, "ap"); + await waitFor(() => ( + expect(screen.queryByRole("option", { name: /banana/ })).not.toBeInTheDocument()) + ); + await waitFor(() => ( + expect(screen.queryByRole("option", { name: /pear/ })).not.toBeInTheDocument()) + ); + screen.getByRole("option", { name: /apple/ }); + screen.getByRole("option", { name: /grape/ }); + // Search for unknown fruit await user.clear(searchInput); await user.type(searchInput, "tomato"); From ca5119104e3f53dcc05017fa4971ef40d4f7162d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 30 Nov 2023 13:23:23 +0000 Subject: [PATCH 57/58] [web] Fix documentation --- web/src/components/core/ListSearch.jsx | 2 +- web/src/utils.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/core/ListSearch.jsx b/web/src/components/core/ListSearch.jsx index 092c30ef73..b3b1b5f52c 100644 --- a/web/src/components/core/ListSearch.jsx +++ b/web/src/components/core/ListSearch.jsx @@ -41,7 +41,7 @@ const search = (elements, term) => { * * @param {object} props * @param {string} [props.placeholder] - * @param {object[]} [props.elements] - List of element in which to search. + * @param {object[]} [props.elements] - List of elements in which to search. * @param {(elements: object[]) => void} - Callback to be called with the filtered list of elements. */ export default function ListSearch({ diff --git a/web/src/utils.js b/web/src/utils.js index 7a4d84afff..1b3c9dab65 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -175,7 +175,7 @@ const useLocalStorage = (storageKey, fallbackState) => { * @example * * const log = useDebounce(console.log, 1000); - * log("test ", 1) // The message will be logged after 1 second. + * log("test ", 1) // The message will be logged after at least 1 second. * log("test ", 2) // Subsequent calls cancels pending calls. */ const useDebounce = (callback, delay) => { From acb8aab0644c577c378aea17b6cca4a66ee3b5fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 30 Nov 2023 13:30:11 +0000 Subject: [PATCH 58/58] [web] Use CSS logical properties --- web/src/assets/styles/blocks.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index 6158d115d7..5ef7deec0c 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -355,8 +355,8 @@ span.notification-mark[data-variant="sidebar"] { .sticky-top-0 { position: sticky; top: calc(-1 * var(--pf-v5-c-modal-box__body--PaddingTop)); - margin-top: calc(-1 * var(--pf-v5-c-modal-box__body--PaddingTop)); - padding-top: var(--pf-v5-c-modal-box__body--PaddingTop); + margin-block-start: calc(-1 * var(--pf-v5-c-modal-box__body--PaddingTop)); + padding-block-start: var(--pf-v5-c-modal-box__body--PaddingTop); background-color: var(--pf-v5-c-modal-box--BackgroundColor); [role="search"] {