Skip to content

Commit

Permalink
Merge pull request #796 from imobachgs/language-switcher
Browse files Browse the repository at this point in the history
Allow changing the language for the web UI
  • Loading branch information
imobachgs authored Oct 19, 2023
2 parents 96cfc18 + 90d7280 commit 1a9e0a3
Show file tree
Hide file tree
Showing 19 changed files with 621 additions and 171 deletions.
5 changes: 4 additions & 1 deletion web/cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"allowCompoundWords": false,
"ignorePaths": [
"src/lib/cockpit.js",
"src/lib/cockpit-po-plugin.js"
"src/lib/cockpit-po-plugin.js",
"src/manifest.json"
],
"import": [
"@cspell/dict-css/cspell-ext.json",
Expand All @@ -23,11 +24,13 @@
"autologin",
"btrfs",
"ccmp",
"čeština",
"chzdev",
"dasd",
"dasds",
"devel",
"dbus",
"Deutsch",
"España",
"filecontent",
"filename",
Expand Down
6 changes: 6 additions & 0 deletions web/package/cockpit-agama.changes
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
-------------------------------------------------------------------
Tue Oct 17 10:59:37 UTC 2023 - Imobach González Sosa <[email protected]>

- Allow changing the language of the user interface
(gh#openSUSE/agama#796).

-------------------------------------------------------------------
Tue Oct 10 08:50:53 UTC 2023 - Ladislav Slezák <[email protected]>

Expand Down
55 changes: 43 additions & 12 deletions web/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) [2022] SUSE LLC
* Copyright (c) [2022-2023] SUSE LLC
*
* All Rights Reserved.
*
Expand All @@ -23,12 +23,13 @@ import React, { useEffect, useState } from "react";
import { Outlet } from "react-router-dom";

import { _ } from "~/i18n";
import { useInstallerClient } from "~/context/installer";
import { useInstallerClient, useInstallerClientStatus } from "~/context/installer";
import { STARTUP, INSTALL } from "~/client/phase";
import { BUSY } from "~/client/status";

import {
About,
DBusError,
Disclosure,
Installation,
IssuesLink,
Expand All @@ -39,10 +40,24 @@ import {
Sidebar
} from "~/components/core";
import { ChangeProductLink } from "~/components/software";
import { SidebarArea } from "~/components/layout";
import { LanguageSwitcher } from "./components/l10n";
import { Layout, Loading, Title } from "./components/layout";
import { useL10n } from "./context/l10n";

// D-Bus connection attempts before displaying an error.
const ATTEMPTS = 3;

/**
* Main application component.
*
* @param {object} props
* @param {number} [props.max_attempts=3] - Connection attempts before displaying an
* error (3 by default). The component will keep trying to connect.
*/
function App() {
const client = useInstallerClient();
const { attempt } = useInstallerClientStatus();
const { language } = useL10n();
const [status, setStatus] = useState(undefined);
const [phase, setPhase] = useState(undefined);

Expand All @@ -54,14 +69,20 @@ function App() {
setStatus(status);
};

loadPhase().catch(console.error);
}, [client.manager, setPhase, setStatus]);
if (client) loadPhase().catch(console.error);
}, [client, setPhase, setStatus]);

useEffect(() => {
return client.manager.onPhaseChange(setPhase);
}, [client.manager, setPhase]);
if (client) {
return client.manager.onPhaseChange(setPhase);
}
}, [client, setPhase]);

const Content = () => {
if (!client) {
return (attempt > ATTEMPTS) ? <DBusError /> : <Loading />;
}

if ((phase === STARTUP && status === BUSY) || phase === undefined || status === undefined) {
return <LoadingEnvironment onStatusChange={setStatus} />;
}
Expand All @@ -75,8 +96,8 @@ function App() {

return (
<>
<SidebarArea>
<Sidebar>
<Sidebar>
<div className="flex-stack">
<ChangeProductLink />
<IssuesLink />
<Disclosure label={_("Diagnostic tools")} data-keep-sidebar-open>
Expand All @@ -85,10 +106,20 @@ function App() {
<ShowTerminalButton />
</Disclosure>
<About />
</Sidebar>
</SidebarArea>
</div>
<div className="full-width highlighted">
<div className="flex-stack">
<LanguageSwitcher />
</div>
</div>
</Sidebar>

<Content />
<Layout>
{/* this is the name of the tool, do not translate it */}
{/* eslint-disable-next-line i18next/no-literal-string */}
<Title>Agama</Title>
{language && <Content />}
</Layout>
</>
);
}
Expand Down
24 changes: 17 additions & 7 deletions web/src/App.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,26 +48,36 @@ const changePhaseTo = phase => act(() => callbacks.onPhaseChange(phase));

describe("App", () => {
beforeEach(() => {
// setting the language through a cookie
document.cookie = "CockpitLang=en-us; path=/;";
createClient.mockImplementation(() => {
return {
manager: {
getStatus: getStatusFn,
getPhase: getPhaseFn,
onPhaseChange: onPhaseChangeFn,
},
isConnected: async () => true,
language: {
getUILanguage: jest.fn().mockResolvedValue("en-us"),
setUILanguage: jest.fn().mockResolvedValue("en-us"),
}
};
});
});

afterEach(() => {
// setting a cookie with already expired date removes it
document.cookie = "CockpitLang=; path=/; expires=" + new Date(0).toUTCString();
});

describe("on the startup phase", () => {
beforeEach(() => {
getPhaseFn.mockResolvedValue(STARTUP);
getStatusFn.mockResolvedValue(BUSY);
});

it("renders the LoadingEnvironment theme", async () => {
installerRender(<App />);
installerRender(<App />, { withL10n: true });
await screen.findByText("LoadingEnvironment Mock");
});
});
Expand All @@ -79,7 +89,7 @@ describe("App", () => {
});

it("renders the LoadingEnvironment component", async () => {
installerRender(<App />);
installerRender(<App />, { withL10n: true });

await screen.findByText("LoadingEnvironment Mock");
});
Expand All @@ -91,7 +101,7 @@ describe("App", () => {
});

it("renders the application content", async () => {
installerRender(<App />);
installerRender(<App />, { withL10n: true });
await screen.findByText(/Outlet Content/);
});
});
Expand All @@ -102,7 +112,7 @@ describe("App", () => {
});

it("renders the application content", async () => {
installerRender(<App />);
installerRender(<App />, { withL10n: true });
await screen.findByText("Installation Mock");
});
});
Expand All @@ -113,7 +123,7 @@ describe("App", () => {
});

it("renders the Installation component on the INSTALL phase", async () => {
installerRender(<App />);
installerRender(<App />, { withL10n: true });
await screen.findByText(/Outlet Content/);
changePhaseTo(INSTALL);
await screen.findByText("Installation Mock");
Expand All @@ -127,7 +137,7 @@ describe("App", () => {
});

it("renders the application's content", async () => {
installerRender(<App />);
installerRender(<App />, { withL10n: true });
await screen.findByText(/Outlet Content/);
});
});
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/core/Sidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,8 @@ export default function Sidebar ({ children }) {
</button>
</header>

<div className="flex-stack" onClick={onClick}>
{ children }
<div className="flex-stack justify-between" onClick={onClick}>
{children}
</div>

<footer className="split justify-between" data-state="reversed">
Expand Down
58 changes: 58 additions & 0 deletions web/src/components/l10n/LanguageSwitcher.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* 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, { useCallback, useState } from "react";
import { Icon } from "../layout";
import { FormSelect, FormSelectOption } from "@patternfly/react-core";
import { _ } from "~/i18n";
import { useL10n } from "~/context/l10n";
import cockpit from "~/lib/cockpit";

export default function LanguageSwitcher() {
const { language, changeLanguage } = useL10n();
const [selected, setSelected] = useState(null);
const languages = cockpit.manifests.agama?.locales || [];

const onChange = useCallback((_event, value) => {
setSelected(value);
changeLanguage(value);
}, [setSelected, changeLanguage]);

const options = Object.entries(languages).map(([id, name]) => {
return <FormSelectOption key={id} value={id} label={name} />;
});

return (
<>
<h3>
<Icon name="translate" size="24" />{_("Display Language")}
</h3>
<FormSelect
id="language"
aria-label={_("language")}
value={selected || language}
onChange={onChange}
>
{options}
</FormSelect>
</>
);
}
63 changes: 63 additions & 0 deletions web/src/components/l10n/LanguageSwitcher.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* 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 { screen } from "@testing-library/react";
import { plainRender } from "~/test-utils";
import LanguageSwitcher from "./LanguageSwitcher";

const mockLanguage = "es-es";
let mockChangeLanguageFn;

jest.mock("~/lib/cockpit", () => ({
gettext: term => term,
manifests: {
agama: {
locales: {
"de-de": "Deutsch",
"en-us": "English (US)",
"es-es": "Español"
}
}
}
}));

jest.mock("~/context/l10n", () => ({
...jest.requireActual("~/context/l10n"),
useL10n: () => ({
language: mockLanguage,
changeLanguage: mockChangeLanguageFn
})
}));

beforeEach(() => {
mockChangeLanguageFn = jest.fn();
});

it("LanguageSwitcher", async () => {
const { user } = plainRender(<LanguageSwitcher />);
expect(screen.getByRole("option", { name: "Español" }).selected).toBe(true);
await user.selectOptions(
screen.getByRole("combobox", { label: "Display Language" }),
screen.getByRole("option", { name: "English (US)" })
);
expect(mockChangeLanguageFn).toHaveBeenCalledWith("en-us");
});
1 change: 1 addition & 0 deletions web/src/components/l10n/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@
*/

export { default as L10nPage } from "./L10nPage";
export { default as LanguageSwitcher } from "./LanguageSwitcher";
Loading

0 comments on commit 1a9e0a3

Please sign in to comment.