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 (
);
}
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 (
+
+ );
+};
-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
-
-
);
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 }) => ;
+
+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 (
+ <>
+
+ {children}
+
+
+
+ }
+ />
+ >
+ );
+};
+
+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 (
+ <>
+
+ {children}
+
+
+
+ }
+ />
+ >
+ );
+};
+
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 }) => ;
+
+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"] {