diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index b470b951e7..90fe1190a8 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,17 @@ +------------------------------------------------------------------- +Fri May 16 07:46:22 UTC 2025 - David Diaz + +- Rework the installer l10n settings (gh#agama-project/agama#2359): + - Improve discoverability of language and keyboard layout settings. + - Add contextual messages to help users differentiate between + installer and product localization settings. + - Add the ability to reuse installer settings for the product + to install. + - In local connections, keyboard layout change is now available + directly from modal dialogs holding password inputs. + - Password inputs now include a reminder of the active layout + (only in local connections) and a CAPS LOCK warning when active. + ------------------------------------------------------------------- Mon May 12 12:47:46 UTC 2025 - Ladislav Slezák diff --git a/web/src/components/core/InstallerOptions.test.tsx b/web/src/components/core/InstallerOptions.test.tsx new file mode 100644 index 0000000000..b75984fd7a --- /dev/null +++ b/web/src/components/core/InstallerOptions.test.tsx @@ -0,0 +1,450 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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, within } from "@testing-library/react"; +import { installerRender, mockRoutes } from "~/test-utils"; +import { InstallationPhase } from "~/types/status"; +import * as utils from "~/utils"; +import { PRODUCT, ROOT } from "~/routes/paths"; +import InstallerOptions, { InstallerOptionsProps } from "./InstallerOptions"; +import { Product } from "~/types/software"; + +let phase: InstallationPhase; +let isBusy: boolean; + +const locales = [ + { id: "en_US.UTF-8", name: "English", territory: "United States" }, + { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, +]; + +const keymaps = [ + { id: "us", name: "English (US)" }, + { id: "gb", name: "English (UK)" }, +]; + +const tumbleweed: Product = { + id: "Tumbleweed", + name: "openSUSE Tumbleweed", + icon: "tumbleweed.svg", + description: "Tumbleweed description...", + registration: false, +}; + +let mockSelectedProduct: Product; + +const mockL10nConfigMutation = { + mutate: jest.fn(), +}; + +const mockChangeUIKeymap = jest.fn(); +const mockChangeUILanguage = jest.fn(); + +jest.mock("~/queries/l10n", () => ({ + ...jest.requireActual("~/queries/l10n"), + useL10n: () => ({ locales, selectedLocale: locales[0] }), + useConfigMutation: () => mockL10nConfigMutation, + keymapsQuery: () => ({ + queryKey: ["keymaps"], + queryFn: () => keymaps, + }), +})); + +jest.mock("~/queries/status", () => ({ + useInstallerStatus: () => ({ + phase, + isBusy, + }), +})); + +jest.mock("~/context/installerL10n", () => ({ + ...jest.requireActual("~/context/installerL10n"), + useInstallerL10n: () => ({ + keymap: "us", + language: "de-DE", + changeKeymap: mockChangeUIKeymap.mockResolvedValue(true), + changeLanguage: mockChangeUILanguage.mockResolvedValue(true), + }), +})); + +jest.mock("~/queries/software", () => ({ + ...jest.requireActual("~/queries/software"), + useProduct: () => { + return { + products: [tumbleweed], + selectedProduct: mockSelectedProduct, + }; + }, +})); + +const renderAndOpen = async (props: InstallerOptionsProps = {}) => { + const { user } = installerRender(, { withL10n: true }); + const toggle = screen.getByRole("button"); + await user.click(toggle); + return { user }; +}; + +describe("InstallerOptions", () => { + beforeEach(() => { + jest.spyOn(utils, "localConnection").mockReturnValue(true); + mockSelectedProduct = tumbleweed; + phase = InstallationPhase.Config; + isBusy = false; + }); + + it("allows custom toggle", async () => { + const { user } = installerRender( + ( + + )} + />, + { withL10n: true }, + ); + const toggle = screen.getByRole("button", { + name: "Change installer settings (Deutsch-us)", + }); + await user.click(toggle); + screen.getByRole("dialog", { name: "Language and keyboard" }); + }); + + describe.each([ + ["login", ROOT.login], + ["product selection progress", PRODUCT.progress], + ["installation progress", ROOT.installationProgress], + ["installation finished", ROOT.installationFinished], + ])(`when the installer is rendering the %s screen`, (_, path) => { + beforeEach(() => { + mockRoutes(path); + }); + + it("renders nothing", () => { + const { container } = installerRender(); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe("when using variant=all", () => { + it("renders a button with current language and keymap values", () => { + installerRender(, { withL10n: true }); + const toggle = screen.getByRole("button", { + name: "Change display language and keyboard layout", + }); + expect(toggle).toHaveTextContent("Deutsch"); + expect(toggle).toHaveTextContent("us"); + }); + + it("allows setting display language and keyboard layout", async () => { + const { user } = await renderAndOpen(); + const dialog = screen.getByRole("dialog", { name: "Language and keyboard" }); + const languageSelector = within(dialog).getByRole("combobox", { name: "Language" }); + const keymapSelector = await within(dialog).findByRole("combobox", { + name: "Keyboard layout", + }); + const acceptButton = within(dialog).getByRole("button", { name: "Accept" }); + + await user.selectOptions(languageSelector, "Español"); + await user.selectOptions(keymapSelector, "English (UK)"); + + await user.click(acceptButton); + expect(mockChangeUIKeymap).toHaveBeenCalledWith("gb"); + expect(mockChangeUILanguage).toHaveBeenCalledWith("es-ES"); + }); + + it("allows reusing settings for the selected product", async () => { + const { user } = await renderAndOpen(); + const dialog = screen.getByRole("dialog", { name: "Language and keyboard" }); + const languageSelector = within(dialog).getByRole("combobox", { name: "Language" }); + const keymapSelector = await within(dialog).findByRole("combobox", { + name: "Keyboard layout", + }); + const reuseSettings = within(dialog).getByRole("checkbox", { + name: /Use these same settings/, + }); + const acceptButton = within(dialog).getByRole("button", { name: "Accept" }); + + expect(reuseSettings).toBeChecked(); + + await user.selectOptions(languageSelector, "Español"); + await user.selectOptions(keymapSelector, "English (UK)"); + + await user.click(acceptButton); + expect(mockL10nConfigMutation.mutate).toHaveBeenCalledWith({ + locales: ["es_ES.UTF-8"], + keymap: "gb", + }); + }); + + it("allows not reusing settings for the selected product", async () => { + const { user } = await renderAndOpen(); + const dialog = screen.getByRole("dialog", { name: "Language and keyboard" }); + const languageSelector = within(dialog).getByRole("combobox", { name: "Language" }); + const keymapSelector = await within(dialog).findByRole("combobox", { + name: "Keyboard layout", + }); + const reuseSettings = within(dialog).getByRole("checkbox", { + name: /Use these same settings/, + }); + const acceptButton = within(dialog).getByRole("button", { name: "Accept" }); + + expect(reuseSettings).toBeChecked(); + await user.click(reuseSettings); + expect(reuseSettings).not.toBeChecked(); + await user.selectOptions(languageSelector, "Español"); + await user.selectOptions(keymapSelector, "English (UK)"); + await user.click(acceptButton); + expect(mockL10nConfigMutation.mutate).not.toHaveBeenCalled(); + }); + + it("includes a link to localization page", async () => { + await renderAndOpen(); + screen.getByRole("link", { name: "Localization" }); + }); + + describe("but a product is not selected yet", () => { + beforeEach(() => { + mockSelectedProduct = undefined; + }); + + it("does not allow reusing setting", async () => { + await renderAndOpen(); + const dialog = screen.getByRole("dialog"); + expect(within(dialog).queryByRole("checkbox")).toBeNull(); + screen.getByText(/This will affect only the installer interface/); + }); + + it("does not include a link to localization page", async () => { + await renderAndOpen(); + expect(screen.queryByRole("link", { name: "Localization" })).toBeNull(); + }); + }); + + describe("but in a remote connection", () => { + beforeEach(() => { + jest.spyOn(utils, "localConnection").mockReturnValue(false); + }); + + it("does not allow setting the keyboard layout", async () => { + const { user } = await renderAndOpen(); + const dialog = screen.getByRole("dialog"); + const languageSelector = within(dialog).getByRole("combobox", { name: "Language" }); + const keymapSelector = within(dialog).queryByRole("combobox", { name: "Keyboard layout" }); + expect(keymapSelector).toBeNull(); + await within(dialog).findByText("Cannot be changed in remote installation"); + await user.selectOptions(languageSelector, "Español"); + const acceptButton = within(dialog).getByRole("button", { name: "Accept" }); + await user.click(acceptButton); + expect(mockChangeUIKeymap).not.toHaveBeenCalled(); + }); + }); + }); + + describe("when using variant=language", () => { + it("renders a button only with current language value", () => { + installerRender(, { withL10n: true }); + const toggle = screen.getByRole("button", { + name: "Change display language", + }); + expect(toggle).toHaveTextContent("Deutsch"); + expect(toggle).not.toHaveTextContent("us"); + }); + + it("allows setting only language", async () => { + const { user } = await renderAndOpen({ variant: "language" }); + const dialog = screen.getByRole("dialog", { name: "Change Language" }); + const languageSelector = within(dialog).getByRole("combobox", { name: "Language" }); + const keymapSelector = within(dialog).queryByRole("combobox", { + name: "Keyboard layout", + }); + expect(keymapSelector).toBeNull(); + const acceptButton = within(dialog).getByRole("button", { name: "Accept" }); + + await user.selectOptions(languageSelector, "Español"); + + await user.click(acceptButton); + expect(mockChangeUILanguage).toHaveBeenCalledWith("es-ES"); + }); + + it("allows reusing settings for the selected product", async () => { + const { user } = await renderAndOpen({ variant: "language" }); + const dialog = screen.getByRole("dialog", { name: "Change Language" }); + const languageSelector = within(dialog).getByRole("combobox", { name: "Language" }); + const reuseSettings = within(dialog).getByRole("checkbox", { + name: /Use for the selected product too/, + }); + const acceptButton = within(dialog).getByRole("button", { name: "Accept" }); + + expect(reuseSettings).toBeChecked(); + + await user.selectOptions(languageSelector, "Español"); + + await user.click(acceptButton); + expect(mockL10nConfigMutation.mutate).toHaveBeenCalledWith({ + locales: ["es_ES.UTF-8"], + }); + }); + + it("allows not reusing settings for the selected product", async () => { + const { user } = await renderAndOpen({ variant: "language" }); + const dialog = screen.getByRole("dialog", { name: "Change Language" }); + const languageSelector = within(dialog).getByRole("combobox", { name: "Language" }); + const reuseSettings = within(dialog).getByRole("checkbox", { + name: /Use for the selected product too/, + }); + const acceptButton = within(dialog).getByRole("button", { name: "Accept" }); + + expect(reuseSettings).toBeChecked(); + await user.click(reuseSettings); + expect(reuseSettings).not.toBeChecked(); + await user.selectOptions(languageSelector, "Español"); + await user.click(acceptButton); + expect(mockL10nConfigMutation.mutate).not.toHaveBeenCalled(); + }); + + it("includes a link to localization page", async () => { + await renderAndOpen({ variant: "language" }); + screen.getByRole("link", { name: "Localization" }); + }); + + describe("but a product is not selected yet", () => { + beforeEach(() => { + mockSelectedProduct = undefined; + }); + + it("does not allow reusing setting", async () => { + await renderAndOpen(); + const dialog = screen.getByRole("dialog"); + expect(within(dialog).queryByRole("checkbox")).toBeNull(); + screen.getByText(/This will affect only the installer interface/); + }); + + it("does not include a link to localization page", async () => { + await renderAndOpen({ variant: "language" }); + expect(screen.queryByRole("link", { name: "Localization" })).toBeNull(); + }); + }); + }); + + describe("when using variant=keyboard", () => { + it("renders a button only with current keymap value", () => { + installerRender(, { withL10n: true }); + const toggle = screen.getByRole("button", { + name: "Change keyboard layout", + }); + expect(toggle).not.toHaveTextContent("Deutsch"); + expect(toggle).toHaveTextContent("us"); + }); + + it("allows setting only keyboard layout", async () => { + const { user } = await renderAndOpen({ variant: "keyboard" }); + const dialog = screen.getByRole("dialog", { name: "Change keyboard" }); + const languageSelector = within(dialog).queryByRole("combobox", { name: "Language" }); + const keymapSelector = await within(dialog).findByRole("combobox", { + name: "Keyboard layout", + }); + expect(languageSelector).toBeNull(); + const acceptButton = within(dialog).getByRole("button", { name: "Accept" }); + + await user.selectOptions(keymapSelector, "English (UK)"); + + await user.click(acceptButton); + expect(mockChangeUIKeymap).toHaveBeenCalledWith("gb"); + expect(mockChangeUILanguage).not.toHaveBeenCalled(); + }); + + it("allows reusing settings for the selected product", async () => { + const { user } = await renderAndOpen({ variant: "keyboard" }); + const dialog = screen.getByRole("dialog", { name: "Change keyboard" }); + const keymapSelector = await within(dialog).findByRole("combobox", { + name: "Keyboard layout", + }); + const reuseSettings = within(dialog).getByRole("checkbox", { + name: /Use for the selected product too/, + }); + const acceptButton = within(dialog).getByRole("button", { name: "Accept" }); + + expect(reuseSettings).toBeChecked(); + + await user.selectOptions(keymapSelector, "English (UK)"); + + await user.click(acceptButton); + expect(mockL10nConfigMutation.mutate).toHaveBeenCalledWith({ + keymap: "gb", + }); + }); + + it("allows not reusing settings for the selected product", async () => { + const { user } = await renderAndOpen({ variant: "keyboard" }); + const dialog = screen.getByRole("dialog", { name: "Change keyboard" }); + const keymapSelector = await within(dialog).findByRole("combobox", { + name: "Keyboard layout", + }); + const reuseSettings = within(dialog).getByRole("checkbox", { + name: /Use for the selected product too/, + }); + const acceptButton = within(dialog).getByRole("button", { name: "Accept" }); + + expect(reuseSettings).toBeChecked(); + await user.click(reuseSettings); + expect(reuseSettings).not.toBeChecked(); + await user.selectOptions(keymapSelector, "English (UK)"); + await user.click(acceptButton); + expect(mockL10nConfigMutation.mutate).not.toHaveBeenCalled(); + }); + + it("includes a link to localization page", async () => { + await renderAndOpen({ variant: "keyboard" }); + screen.getByRole("link", { name: "Localization" }); + }); + + describe("but in a remote connection", () => { + beforeEach(() => { + jest.spyOn(utils, "localConnection").mockReturnValue(false); + }); + + it("renders nothing", () => { + const { container } = installerRender(, { + withL10n: true, + }); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe("but a product is not selected yet", () => { + beforeEach(() => { + mockSelectedProduct = undefined; + }); + + it("does not allow reusing setting", async () => { + await renderAndOpen(); + const dialog = screen.getByRole("dialog"); + expect(within(dialog).queryByRole("checkbox")).toBeNull(); + screen.getByText(/This will affect only the installer interface/); + }); + + it("does not include a link to localization page", async () => { + await renderAndOpen({ variant: "keyboard" }); + expect(screen.queryByRole("link", { name: "Localization" })).toBeNull(); + }); + }); + }); +}); diff --git a/web/src/components/core/InstallerOptions.tsx b/web/src/components/core/InstallerOptions.tsx index 0e9e0bf966..c7f2bfe506 100644 --- a/web/src/components/core/InstallerOptions.tsx +++ b/web/src/components/core/InstallerOptions.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2024] SUSE LLC + * Copyright (c) [2022-2025] SUSE LLC * * All Rights Reserved. * @@ -20,107 +20,623 @@ * find current contact information at www.suse.com. */ -import React, { useState } from "react"; -import { Flex, Form, FormGroup, FormSelect, FormSelectOption } from "@patternfly/react-core"; +/** + * This module defines the InstallerOptions component, which allows users to + * configure installer localization settings, with the option to copy them, when + * applicable, to the product's localization settings. + * + * It supports multiple UI variants (language-only, keyboard-only, or both), and + * manages both form and dialog state. To avoid scattering complex conditional + * logic throughout the main component, the implementation is split into several + * small internal components. + */ + +import React, { useReducer } from "react"; +import { useHref, useLocation } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { + Button, + ButtonProps, + Checkbox, + Flex, + FlexProps, + Form, + FormGroup, + FormSelect, + FormSelectOption, + FormSelectProps, +} from "@patternfly/react-core"; import { Popup } from "~/components/core"; -import { _ } from "~/i18n"; -import { localConnection } from "~/utils"; +import { Icon } from "~/components/layout"; +import { LocaleConfig } from "~/types/l10n"; +import { InstallationPhase } from "~/types/status"; import { useInstallerL10n } from "~/context/installerL10n"; +import { keymapsQuery, useConfigMutation, useL10n } from "~/queries/l10n"; +import { useInstallerStatus } from "~/queries/status"; +import { localConnection } from "~/utils"; +import { _ } from "~/i18n"; import supportedLanguages from "~/languages.json"; -import { useQuery } from "@tanstack/react-query"; -import { keymapsQuery } from "~/queries/l10n"; +import { PRODUCT, ROOT, L10N } from "~/routes/paths"; +import { useProduct } from "~/queries/software"; -type InstallerOptionsProps = { - isOpen: boolean; - onClose?: () => void; +/** + * Props for select inputs + */ +type SelectProps = { + value: string; + onChange: FormSelectProps["onChange"]; }; /** - * Renders the installer options + * Renders a dropdown for language selection. + */ +const LangaugeFormInput = ({ value, onChange }: SelectProps) => ( + + + {Object.keys(supportedLanguages) + .sort() + .map((id, index) => ( + + ))} + + +); + +/** + * Renders a dropdown for keyboard layout selection. * - * @todo Write documentation - */ -export default function InstallerOptions({ isOpen = false, onClose }: InstallerOptionsProps) { - const { - language: initialLanguage, - keymap: initialKeymap, - changeLanguage, - changeKeymap, - } = useInstallerL10n(); - const [language, setLanguage] = useState(initialLanguage); - const [keymap, setKeymap] = useState(initialKeymap); + * Not available in remote installations. + */ +const KeyboardFormInput = ({ value, onChange }: SelectProps) => { const { isPending, data: keymaps } = useQuery(keymapsQuery()); - const [inProgress, setInProgress] = useState(false); - if (isPending) return; - const close = () => { - setLanguage(initialLanguage); - setKeymap(initialKeymap); - onClose(); - }; + if (!localConnection()) { + return ( + + {_("Cannot be changed in remote installation")} + + ); + } - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setInProgress(true); - await changeKeymap(keymap); - changeLanguage(language) - .then(close) - .catch(() => setInProgress(false)); - }; + return ( + + + {keymaps.map((keymap, index) => ( + + ))} + + + ); +}; + +/** + * Represents the form state. + */ +type FormState = { + /** The language code */ + language: string; + /** The keymap code */ + keymap: string; + /** Whether reusing settings for the product feature is availabler or not */ + allowReusingSettings: boolean; + /** Whether reuse these settings for the product localization settings too */ + reuseSettings: boolean; +}; + +/** + * Supported form actions. + */ +type FormAction = + | { type: "SET_SELECTED_LANGUAGE"; language: string } + | { type: "SET_SELECTED_KEYMAP"; keymap: string } + | { type: "TOGGLE_REUSE_SETTINGS" } + | { type: "RESET"; state: FormState }; + +/** + * Reducer for form state updates. + */ +const formReducer = (state: FormState, action: FormAction): FormState => { + switch (action.type) { + case "SET_SELECTED_LANGUAGE": { + return { ...state, language: action.language }; + } + + case "SET_SELECTED_KEYMAP": { + return { ...state, keymap: action.keymap }; + } + + case "TOGGLE_REUSE_SETTINGS": { + return { ...state, reuseSettings: !state.reuseSettings }; + } + + case "RESET": { + return { ...action.state }; + } + } +}; + +/** + * Supported dialog actions. + */ +type DialogAction = + | { type: "OPEN" } + | { type: "CLOSE" } + | { type: "SET_BUSY" } + | { type: "SET_IDLE" }; + +/** + * Represents the dialog state + */ +type DialogState = { + isOpen: boolean; + isBusy: boolean; +}; + +/** + * Reducer for form state updates. + */ +const dialogReducer = (state: DialogState, action: DialogAction): DialogState => { + switch (action.type) { + case "OPEN": { + return { ...state, isOpen: true }; + } + + case "CLOSE": { + return { isOpen: false, isBusy: false }; + } + + case "SET_BUSY": { + return { ...state, isBusy: true }; + } + + case "SET_IDLE": { + return { ...state, isBusy: false }; + } + } +}; + +/** + * Available actions for handling dialog and form events. + */ +type Actions = { + handleLanguageChange: (_, v: string) => void; + handleKeymapChange: (_, v: string) => void; + handleCopyToSystemToggle: (_, v: boolean) => void; + handleSubmitForm: (e: React.FormEvent) => void; + handleCloseDialog: () => void; +}; + +/** + * Props passed to each dialog variant. + */ +type DialogProps = { + state: DialogState; + formState: FormState; + actions: Actions; +}; + +/** + * Defines the available installer options modes: + * "all": Allow settings both language and keyboard layout. + * "language": Allow setting only language. + * "keyboard": Allow settings only keyboard layout. + */ +type InstallerOptionsVariant = "all" | "language" | "keyboard"; + +/** + * Props passed to each toggle variant. + */ +type ToggleProps = Pick & { + language?: string; + keymap?: string; +}; + +/** + * A component that conditionally displays content based on whether settings can + * be reused. + * + * If reuse is allowed, the content (children) is rendered. + * If reuse is not allowed, a fallback message is displayed instead. + * + * This component helps avoid repeating the same condition in each form variant, + * as the fallback message should remain the same for all of them. + */ +const ReusableSettings = ({ isReuseAllowed, children }) => { + if (isReuseAllowed) { + return children; + } else { + // TRANSLATORS: This message informs users that they are only changing the + // interface language and/or keyboard settings here. The term "localization" + // is the name of a separate page where they can configure the localization + // settings for the product to install. + return _( + "This will affect only the installer interface, not the product to be installed. You can adjust the product’s localization later in the Localization settings page.", + ); + } +}; + +type TextWithLinkToL10nProps = { + /** The text containing a bracketed substring for the link. */ + text: string; + /** + * Optional handler triggered when the user activates the link. Useful for + * performing side effects, such as closing the dialog. Navigation may not occur + * if the user is already on the L10n page. This callback runs regardless of + * whether navigation happens. + */ + onClick?: ButtonProps["onClick"]; +}; + +/** + * Renders a string with an inline link that navigates to the Localization page. + * + * The input `text` must include a substring wrapped in square brackets `[ ]`, which will be replaced + * by a clickable link. The component splits the text into three parts: + * - The content before `[link text]` + * - The content inside the brackets (used as the link text) + * - The content after the brackets + * + * Example input: + * "You can configure the langauge for the product to install at [Localization page]." + * + * @param {text} props - The text containing a bracketed substring for the link. + */ +const TextWithLinkToL10n = ({ text, onClick }: TextWithLinkToL10nProps) => { + const href = useHref(L10N.root); + const [textStart, l10nPageLink, textEnd] = text.split(/[[\]]/); + + return ( + <> + {textStart}{" "} + {" "} + {textEnd} + + ); +}; + +const AllSettingsDialog = ({ state, formState, actions }: DialogProps) => { + const checkboxDescription = _( + // TRANSLATORS: Explains where users can find more language and keymap + // options for the product to install. Keep the text in square brackets [] + // as it will be replaced with a clickable link. + "More language and keyboard layout options for the selected product may be available in [Localization] page.", + ); + + return ( + +
+ + + + + + } + isChecked={formState.reuseSettings} + onChange={actions.handleCopyToSystemToggle} + /> + + + + + + + {_("Accept")} + + + +
+ ); +}; + +const LanguageOnlyDialog = ({ state, formState, actions }: DialogProps) => { + const checkboxDescription = _( + // TRANSLATORS: Explains where users can find more languages options for the + // product to install. Keep the text in square brackets [] as it will be + // replaced with a clickable link. + "More languages might be available for the selected product at [Localization] page", + ); return ( - - -
- - setLanguage(value)} - > - {Object.keys(supportedLanguages) - .sort() - .map((id, index) => ( - - ))} - + + + + + + + } + isChecked={formState.reuseSettings} + onChange={actions.handleCopyToSystemToggle} + /> + + - - {localConnection() ? ( - setKeymap(value)} - > - {keymaps.map((keymap, index) => ( - - ))} - - ) : ( - _("Cannot be changed in remote installation") - )} + + + {_("Accept")} + + + + + ); +}; + +const KeyboardOnlyDialog = ({ state, formState, actions }: DialogProps) => { + if (!localConnection()) { + return ( + + {_("Cannot be changed in remote installation")} + + {_("Accept")} + + + ); + } + + const checkboxDescription = _( + // TRANSLATORS: Explains where users can find more keymap options for the + // product to install. Keep the text in square brackets [] as it will be + // replaced with a clickable link. + "More keymap layout might be available for the selected product at [Localization] page", + ); + + return ( + +
+ + + + + } + isChecked={formState.reuseSettings} + onChange={actions.handleCopyToSystemToggle} + /> - -
+ + + {_("Accept")} - +
); +}; + +/** Icon representing the language settings. Used in toggle buttons. */ +const LanguageIcon = () => ; + +/** Icon representing the keyboard settings. Used in toggle buttons. */ +const KeyboardIcon = () => ; + +/** A layout helper that centers its children with spacing. Used in toggle buttons. */ +const CenteredContent = ({ + children, + alignItems = "alignItemsCenter", +}: React.PropsWithChildren<{ alignItems?: FlexProps["alignItems"]["default"] }>) => ( + + {children} + +); + +/** Toggle button for accessing both language and keyboard layout settings. */ +const AllSettingsToggle = ({ onClick, language, keymap }: ToggleProps) => ( + +); + +/** Toggle button for accessing only language settings. */ +const LanguageOnlyToggle = ({ onClick, language }: ToggleProps) => ( + +); + +/** Toggle button for accessing only keymap settings. */ +const KeyboardOnlyToggle = ({ onClick, keymap }: ToggleProps) => ( + +); + +/** + * Maps each dialog variant to its corresponding React component. + */ +const dialogs: { [key in InstallerOptionsVariant]: React.FC } = { + all: AllSettingsDialog, + language: LanguageOnlyDialog, + keyboard: KeyboardOnlyDialog, +}; + +/** + * Maps each toggle variant to its corresponding React component. + */ +const toggles: { [key in InstallerOptionsVariant]: React.FC } = { + all: AllSettingsToggle, + language: LanguageOnlyToggle, + keyboard: KeyboardOnlyToggle, +}; + +/** + * Props for the main InstallerOptions component. + */ +export type InstallerOptionsProps = { + /** Determines which dialog variant to render. */ + variant?: InstallerOptionsVariant; + /** + * Optional render function for a custom button or UI element that opens the + * dialog. If not provided, a default toggle button will be rendered based on + * the selected variant. + */ + toggle?: (props: ToggleProps) => JSX.Element; + /** Optional callback when the dialog is closed. */ + onClose?: () => void; +}; + +/** + * Dialog for setting language and keyboard layout. + * + * It supports different through its "variant" prop: language-only, + * keyboard-only, or both. + * + */ +export default function InstallerOptions({ + variant = "all", + toggle, + onClose, +}: InstallerOptionsProps) { + const location = useLocation(); + const { locales } = useL10n(); + const { mutate: updateSystemL10n } = useConfigMutation(); + const { language, keymap, changeLanguage, changeKeymap } = useInstallerL10n(); + const { phase } = useInstallerStatus({ suspense: true }); + const { selectedProduct } = useProduct({ suspense: true }); + const initialFormState = { + language, + keymap, + allowReusingSettings: !!selectedProduct, + reuseSettings: true, + }; + const [formState, dispatch] = useReducer(formReducer, initialFormState); + const [dialogState, dispatchDialogAction] = useReducer(dialogReducer, { + isOpen: false, + isBusy: false, + }); + + // Skip rendering if any of the following conditions are met + const skip = + (variant === "keyboard" && !localConnection()) || + phase === InstallationPhase.Install || + // FIXME: below condition could be a problem for a question appearing while + // product progress + [ROOT.login, ROOT.installationProgress, ROOT.installationFinished, PRODUCT.progress].includes( + location.pathname, + ); + + if (skip) return; + + /** + * Copies selected localization settings to the product to install settings, + **/ + const reuseSettings = () => { + // FIXME: export and use languageToLocale from context/installerL10n + const systemLocale = locales.find((l) => l.id.startsWith(formState.language.replace("-", "_"))); + const systemL10n: Partial = {}; + // FIXME: use a fallback if no system locale was found ? + if (variant !== "keyboard") systemL10n.locales = [systemLocale?.id]; + if (variant !== "language" && localConnection()) systemL10n.keymap = formState.keymap; + + updateSystemL10n(systemL10n); + }; + + const close = () => { + dispatch({ type: "RESET", state: initialFormState }); + dispatchDialogAction({ type: "CLOSE" }); + typeof onClose === "function" && onClose(); + }; + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + dispatchDialogAction({ type: "SET_BUSY" }); + + try { + if (variant !== "language" && localConnection()) { + await changeKeymap(formState.keymap); + } + + if (variant !== "keyboard") { + await changeLanguage(formState.language); + } + + formState.allowReusingSettings && formState.reuseSettings && reuseSettings(); + } catch (e) { + console.error(e); + dispatchDialogAction({ type: "SET_IDLE" }); + } finally { + close(); + } + }; + + const actions: Actions = { + handleLanguageChange: (_, v) => dispatch({ type: "SET_SELECTED_LANGUAGE", language: v }), + handleKeymapChange: (_, v) => dispatch({ type: "SET_SELECTED_KEYMAP", keymap: v }), + handleCopyToSystemToggle: () => dispatch({ type: "TOGGLE_REUSE_SETTINGS" }), + handleSubmitForm: onSubmit, + handleCloseDialog: close, + }; + + const Toggle = toggle ?? toggles[variant]; + const Dialog = dialogs[variant]; + + return ( + <> + dispatchDialogAction({ type: "OPEN" })} + /> + + + ); } diff --git a/web/src/components/core/LoginPage.test.tsx b/web/src/components/core/LoginPage.test.tsx index 8256357ee8..0dd5d4ff31 100644 --- a/web/src/components/core/LoginPage.test.tsx +++ b/web/src/components/core/LoginPage.test.tsx @@ -40,6 +40,8 @@ jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
ProductRegistrationAlert Mock
)); +jest.mock("~/components/layout/Header", () => () =>
Header Mock
); + jest.mock("~/queries/status", () => ({ useInstallerStatus: () => ({ phase, diff --git a/web/src/components/core/LoginPage.tsx b/web/src/components/core/LoginPage.tsx index 50e98c3290..960e2421d0 100644 --- a/web/src/components/core/LoginPage.tsx +++ b/web/src/components/core/LoginPage.tsx @@ -114,6 +114,7 @@ user privileges.", value={password} aria-label={_("Password input")} onChange={(_, v) => setPassword(v)} + reminders={["capslock"]} /> diff --git a/web/src/components/core/PasswordAndConfirmationInput.test.tsx b/web/src/components/core/PasswordAndConfirmationInput.test.tsx index 27e3b0f3ae..0e010cb707 100644 --- a/web/src/components/core/PasswordAndConfirmationInput.test.tsx +++ b/web/src/components/core/PasswordAndConfirmationInput.test.tsx @@ -22,13 +22,15 @@ import React from "react"; import { screen } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; +import { installerRender } from "~/test-utils"; import PasswordAndConfirmationInput from "./PasswordAndConfirmationInput"; describe("when the passwords do not match", () => { it("displays a warning", async () => { const password = ""; - const { user } = plainRender(); + const { user } = installerRender(, { + withL10n: true, + }); const passwordInput = screen.getByLabelText("Password"); user.type(passwordInput, "123456"); @@ -37,7 +39,7 @@ describe("when the passwords do not match", () => { }); it("uses the given password value for confirmation too", async () => { - plainRender(); + installerRender(, { withL10n: true }); const passwordInput = screen.getByLabelText("Password") as HTMLInputElement; const confirmationInput = screen.getByLabelText("Password confirmation") as HTMLInputElement; @@ -48,7 +50,7 @@ it("uses the given password value for confirmation too", async () => { describe("when isDisabled", () => { it("disables both, password and confirmation", async () => { - plainRender(); + installerRender(, { withL10n: true }); const passwordInput = screen.getByLabelText("Password"); const confirmationInput = screen.getByLabelText("Password confirmation"); @@ -69,7 +71,7 @@ describe("when isDisabled", () => { ); }; - const { user } = plainRender(); + const { user } = installerRender(, { withL10n: true }); const passwordInput = screen.getByLabelText("Password"); user.type(passwordInput, "123456"); await screen.findByText("Passwords do not match"); diff --git a/web/src/components/core/PasswordAndConfirmationInput.tsx b/web/src/components/core/PasswordAndConfirmationInput.tsx index 6b06e07244..476e027436 100644 --- a/web/src/components/core/PasswordAndConfirmationInput.tsx +++ b/web/src/components/core/PasswordAndConfirmationInput.tsx @@ -113,6 +113,7 @@ const PasswordAndConfirmationInput = ({ onChange={onConfirmationChange} onBlur={() => validate(password, confirmation)} validated={error === "" ? "default" : "error"} + reminders={[]} /> diff --git a/web/src/components/core/PasswordInput.test.tsx b/web/src/components/core/PasswordInput.test.tsx index ad4f5aca27..222a59805c 100644 --- a/web/src/components/core/PasswordInput.test.tsx +++ b/web/src/components/core/PasswordInput.test.tsx @@ -22,20 +22,33 @@ import React, { useState } from "react"; import { screen } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; +import { installerRender } from "~/test-utils"; import userEvent from "@testing-library/user-event"; import PasswordInput, { PasswordInputProps } from "./PasswordInput"; +import * as utils from "~/utils"; -describe("PasswordInput Component", () => { +jest.mock("~/context/installerL10n", () => ({ + ...jest.requireActual("~/context/installerL10n"), + useInstallerL10n: () => ({ + keymap: "us", + language: "de-DE", + }), +})); + +describe("PasswordInput", () => { it("renders a password input", () => { - plainRender(); + installerRender(, { + withL10n: true, + }); const inputField = screen.getByLabelText("User password"); expect(inputField).toHaveAttribute("type", "password"); }); it("allows revealing the password", async () => { - plainRender(); + installerRender(, { + withL10n: true, + }); const passwordInput = screen.getByLabelText("User password"); const button = screen.getByRole("button"); @@ -46,8 +59,9 @@ describe("PasswordInput Component", () => { }); it("applies autoFocus behavior correctly", () => { - plainRender( + installerRender( , + { withL10n: true }, ); const inputField = screen.getByLabelText("User password"); @@ -69,8 +83,9 @@ describe("PasswordInput Component", () => { }; it("triggers onChange callback", async () => { - const { user } = plainRender( + const { user } = installerRender( , + { withL10n: true }, ); const passwordInput = screen.getByLabelText("Test password"); @@ -79,4 +94,87 @@ describe("PasswordInput Component", () => { screen.getByText("Password value updated!"); }); + + it("renders keyboard reminders by default", async () => { + const { user } = installerRender( + , + { + withL10n: true, + }, + ); + + screen.getByLabelText("User password"); + screen.getByText(/^Using/); + screen.getByText("us"); + screen.getByText(/keyboard$/); + await user.keyboard("{CapsLock}"); + screen.getByText(/^CAPS LOCK/); + screen.getByText(/is on$/); + await user.keyboard("{CapsLock}"); + expect(screen.queryByText(/^CAPS LOCK/)).toBeNull(); + }); + + it("allow disabling reminders via reminders prop", async () => { + const { user } = installerRender( + , + { + withL10n: true, + }, + ); + + expect(screen.queryByText(/^Using/)).toBeNull(); + expect(screen.queryByText(/^CAPS LOCK/)).toBeNull(); + await user.keyboard("{CapsLock}"); + expect(screen.queryByText(/^CAPS LOCK/)).toBeNull(); + }); + + it("allows picking only the keymap reminder", async () => { + const { user } = installerRender( + , + { + withL10n: true, + }, + ); + + screen.getByText(/^Using/); + await user.keyboard("{CapsLock}"); + expect(screen.queryByText(/^CAPS LOCK/)).toBeNull(); + expect(screen.queryByText(/is on$/)).toBeNull(); + }); + + it("allows picking only the caps locsk reminder", async () => { + const { user } = installerRender( + , + { + withL10n: true, + }, + ); + + expect(screen.queryByText(/^Using/)).toBeNull(); + await user.keyboard("{CapsLock}"); + screen.getByText(/^CAPS LOCK/); + screen.getByText(/is on$/); + await user.keyboard("{CapsLock}"); + expect(screen.queryByText(/^CAPS LOCK/)).toBeNull(); + }); + + it("does not render the keymap reminder in remote connections", () => { + jest.spyOn(utils, "localConnection").mockReturnValue(false); + + installerRender(, { + withL10n: true, + }); + + expect(screen.queryByText(/^Using/)).toBeNull(); + }); }); diff --git a/web/src/components/core/PasswordInput.tsx b/web/src/components/core/PasswordInput.tsx index e1352960de..f93275627b 100644 --- a/web/src/components/core/PasswordInput.tsx +++ b/web/src/components/core/PasswordInput.tsx @@ -23,6 +23,7 @@ import React, { useState } from "react"; import { Button, + FormHelperText, InputGroup, InputGroupItem, TextInput, @@ -30,6 +31,18 @@ import { } from "@patternfly/react-core"; import { _ } from "~/i18n"; import { Icon } from "~/components/layout"; +import { useInstallerL10n } from "~/context/installerL10n"; +import { sprintf } from "sprintf-js"; +import { useKeyLock } from "~/hooks/use-key-lock"; +import { localConnection } from "~/utils"; +import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; + +/** + * Types of keyboard-related reminders to the user. + * "keymap": Reminder about the current keyboard layout in use. + * "capslock": Warning that Caps Lock is enabled. + */ +type KeyboardReminders = "keymap" | "capslock"; /** * Props matching the {@link https://www.patternfly.org/components/forms/text-input PF/TextInput}, @@ -37,15 +50,77 @@ import { Icon } from "~/components/layout"; */ export type PasswordInputProps = Omit & { inputRef?: React.Ref; + reminders?: KeyboardReminders[]; }; /** - * Renders a password input field and a toggle button that can be used to reveal - * and hide the password - * @component - * + * Displays a text about keyboard layout is use, unless in remote connection + */ +const KeymapReminder = () => { + const { keymap } = useInstallerL10n(); + + if (!localConnection()) return; + + const [textStart, layout, textEnd] = sprintf( + // TRANSLATORS: Message to inform users which keyboard layout is active. %s + // will be replaced with the layout name (e.g., "de", "cn", "cz"). Keep + // square brackets around %s to apply special formatting in + // the UI. + _("Using [%s] keyboard"), + keymap, + ).split(/[[\]]/); + + return ( + + {textStart} {layout} {textEnd} + + ); +}; + +/** + * Displays a message when Caps Lock is active. + */ +const CapsLockReminder = () => { + const isCapsLockOn = useKeyLock("CapsLock"); + + if (!isCapsLockOn) return; + + // TRANSLATORS: Warns users that CAPS LOCK is on. + // Keep square brackets to apply special formatting in the UI. + const [textStart, capsLock, textEnd] = _("[CAPS LOCK] is on").split(/[[\]]/); + + return ( + + {textStart} {capsLock} {textEnd} + + ); +}; + +/** + * Shows one or more keyboard-related reminders based on the provided list. + * Used below the password input to help prevent typing issues. + */ +const Reminders = ({ display = [] }: { display: KeyboardReminders[] }) => { + if (display.length === 0) return; + + return ( + + {display.includes("keymap") && } + {display.includes("capslock") && } + + ); +}; + +/** + * A password input field with a toggle button to show or hide the password, and + * optional keyboard-related reminders. */ -export default function PasswordInput({ id, inputRef, ...props }: PasswordInputProps) { +export default function PasswordInput({ + id, + inputRef, + reminders = ["keymap", "capslock"], + ...props +}: PasswordInputProps) { const [showPassword, setShowPassword] = useState(false); const visibilityIconName = showPassword ? "visibility_off" : "visibility"; @@ -57,23 +132,26 @@ export default function PasswordInput({ id, inputRef, ...props }: PasswordInputP } return ( - - - - - - - - + <> + + + + + + + + + + ); } diff --git a/web/src/components/l10n/L10nPage.test.tsx b/web/src/components/l10n/L10nPage.test.tsx index d9bbb380be..ae84c15dfd 100644 --- a/web/src/components/l10n/L10nPage.test.tsx +++ b/web/src/components/l10n/L10nPage.test.tsx @@ -25,10 +25,6 @@ import { screen, within } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import L10nPage from "~/components/l10n/L10nPage"; -jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( -
ProductRegistrationAlert Mock
-)); - let mockLoadedData; const locales = [ @@ -46,7 +42,14 @@ const timezones = [ { id: "Europe/Madrid", parts: ["Europe", "Madrid"] }, ]; +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
ProductRegistrationAlert Mock
+)); + +jest.mock("~/components/core/InstallerOptions", () => () =>
InstallerOptions Mock
); + jest.mock("~/queries/l10n", () => ({ + ...jest.requireActual("~/queries/l10n"), useL10n: () => mockLoadedData, })); @@ -61,6 +64,13 @@ beforeEach(() => { }; }); +it("renders an clarification about settings", () => { + installerRender(); + screen.getByText(/These are the settings for the product to install/); + screen.getByText(/The installer language and keyboard layout can be adjusted via/); + screen.getByText("InstallerOptions Mock"); +}); + it("renders a section for configuring the language", () => { installerRender(); const region = screen.getByRole("region", { name: "Language" }); diff --git a/web/src/components/l10n/L10nPage.tsx b/web/src/components/l10n/L10nPage.tsx index a63c5c150a..d82a901e86 100644 --- a/web/src/components/l10n/L10nPage.tsx +++ b/web/src/components/l10n/L10nPage.tsx @@ -21,11 +21,44 @@ */ import React from "react"; -import { Content, Grid, GridItem } from "@patternfly/react-core"; -import { Link, Page } from "~/components/core"; +import { Button, Content, Grid, GridItem } from "@patternfly/react-core"; +import { InstallerOptions, Link, Page } from "~/components/core"; import { L10N as PATHS } from "~/routes/paths"; import { useL10n } from "~/queries/l10n"; import { _ } from "~/i18n"; +import { localConnection } from "~/utils"; + +const InstallerL10nSettingsInfo = () => { + const info = localConnection() + ? // TRANSLATORS: Text used for helping user to set the interface language + // and keymap from product localization options. Text in the square brackets [] is + // used for the link to open the settings panel, please keep the brackets. + _( + "These are the settings for the product to install. The installer language and keyboard layout can be adjusted via the [settings panel] accessible from the top bar.", + ) + : // TRANSLATORS: Text used for helping user to set the interface language + // from product localization options. Text in the square brackets [] is used + // for the link to open the settings panel, please keep the brackets. + _( + "These are the settings for the product to install. The installer language can be adjusted via the [settings panel] accessible from the top bar.", + ); + + const [infoStart, infoLink, infoEnd] = info.split(/[[\]]/); + + return ( + + {infoStart}{" "} + ( + + )} + /> + {infoEnd} + + ); +}; // FIXME: re-evaluate the need of "Thing not selected yet" @@ -83,6 +116,11 @@ export default function L10nPage() { + + + + + diff --git a/web/src/components/layout/Header.test.tsx b/web/src/components/layout/Header.test.tsx index 2d8a3f96d9..057065307b 100644 --- a/web/src/components/layout/Header.test.tsx +++ b/web/src/components/layout/Header.test.tsx @@ -22,10 +22,9 @@ import React from "react"; import { screen, within } from "@testing-library/react"; -import { installerRender, mockRoutes } from "~/test-utils"; -import Header from "./Header"; -import { InstallationPhase } from "~/types/status"; +import { plainRender, installerRender } from "~/test-utils"; import { Product } from "~/types/software"; +import Header from "./Header"; const tumbleweed: Product = { id: "Tumbleweed", @@ -41,9 +40,6 @@ const microos: Product = { registration: false, }; -let phase: InstallationPhase; -let isBusy: boolean; - jest.mock("~/components/core/InstallerOptions", () => () =>
Installer Options Mock
); jest.mock("~/components/core/InstallButton", () => () =>
Install Button Mock
); @@ -55,30 +51,9 @@ jest.mock("~/queries/software", () => ({ useRegistration: () => undefined, })); -jest.mock("~/queries/status", () => ({ - useInstallerStatus: () => ({ - phase, - isBusy, - }), -})); - -const doesNotRenderInstallerL10nOptions = () => - it("does not render the installer localization options", async () => { - const { user } = installerRender(
); - const toggler = screen.getByRole("button", { name: "Options toggle" }); - await user.click(toggler); - const menu = screen.getByRole("menu"); - expect(within(menu).queryByRole("menuitem", { name: "Installer Options" })).toBeNull(); - }); - describe("Header", () => { - beforeEach(() => { - phase = InstallationPhase.Config; - isBusy = false; - }); - it("renders the product name unless showProductName is set to false", () => { - const { rerender } = installerRender(
); + const { rerender } = plainRender(
); screen.getByRole("heading", { name: tumbleweed.name, level: 1 }); rerender(
); screen.getByRole("heading", { name: tumbleweed.name, level: 1 }); @@ -87,17 +62,22 @@ describe("Header", () => { }); it("mounts the Install button", () => { - installerRender(
); + plainRender(
); screen.getByText("Install Button Mock"); }); + it("mounts InstallerOptions", () => { + plainRender(
); + screen.getByText("Installer Options Mock"); + }); + it("renders skip to content link", async () => { - installerRender(
); + plainRender(
); screen.getByRole("link", { name: "Skip to content" }); }); it("does not render skip to content link when showSkipToContent is false", async () => { - installerRender(
); + plainRender(
); expect(screen.queryByRole("link", { name: "Skip to content" })).toBeNull(); }); @@ -109,32 +89,7 @@ describe("Header", () => { const menu = screen.getByRole("menu"); within(menu).getByRole("menuitem", { name: "Change product" }); within(menu).getByRole("menuitem", { name: "Download logs" }); - within(menu).getByRole("menuitem", { name: "Installer Options" }); }); it.todo("allows downloading the logs"); - - describe("at install phase", () => { - beforeEach(() => { - phase = InstallationPhase.Install; - }); - - doesNotRenderInstallerL10nOptions(); - }); - - describe("at /products/progress path", () => { - beforeEach(() => { - mockRoutes("/products/progress"); - }); - - doesNotRenderInstallerL10nOptions(); - }); - - describe("at /login path", () => { - beforeEach(() => { - mockRoutes("/login"); - }); - - doesNotRenderInstallerL10nOptions(); - }); }); diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 739582b3e1..3cdeda500f 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -23,7 +23,6 @@ import React, { useState } from "react"; import { Content, - Divider, Dropdown, DropdownItem, DropdownList, @@ -40,13 +39,11 @@ import { ToolbarGroup, ToolbarItem, } from "@patternfly/react-core"; +import { useMatches } from "react-router-dom"; import { Icon } from "~/components/layout"; import { useProduct } from "~/queries/software"; -import { InstallationPhase } from "~/types/status"; -import { useInstallerStatus } from "~/queries/status"; import { Route } from "~/types/routes"; import { ChangeProductOption, InstallButton, InstallerOptions, SkipTo } from "~/components/core"; -import { useLocation, useMatches } from "react-router-dom"; import { ROOT } from "~/routes/paths"; import { _ } from "~/i18n"; @@ -65,56 +62,37 @@ export type HeaderProps = { toggleSidebar?: () => void; }; -const OptionsDropdown = ({ showInstallerOptions }) => { +const OptionsDropdown = () => { const [isOpen, setIsOpen] = useState(false); - const [isInstallerOptionsOpen, setIsInstallerOptionsOpen] = useState(false); const toggle = () => setIsOpen(!isOpen); - const toggleInstallerOptions = () => setIsInstallerOptionsOpen(!isInstallerOptionsOpen); return ( - <> - document.body }} - isOpen={isOpen} - onOpenChange={toggle} - onSelect={toggle} - onActionClick={toggle} - toggle={(toggleRef: React.Ref) => ( - - - - )} - > - - - - {_("Download logs")} - - {showInstallerOptions && ( - <> - - - {_("Installer Options")} - - - )} - - - - {showInstallerOptions && ( - setIsInstallerOptionsOpen(false)} - /> + document.body }} + isOpen={isOpen} + onOpenChange={toggle} + onSelect={toggle} + onActionClick={toggle} + toggle={(toggleRef: React.Ref) => ( + + + )} - + > + + + + {_("Download logs")} + + + ); }; @@ -132,19 +110,12 @@ export default function Header({ isSidebarOpen, toggleSidebar, }: HeaderProps): React.ReactNode { - const location = useLocation(); const { selectedProduct } = useProduct(); - const { phase } = useInstallerStatus({ suspense: true }); const routeMatches = useMatches() as Route[]; const currentRoute = routeMatches.at(-1); // TODO: translate title const title = (showProductName && selectedProduct?.name) || currentRoute?.handle?.title; - const showInstallerOptions = - phase !== InstallationPhase.Install && - // FIXME: Installer options should be available in the login too. - !["/login", "/products/progress"].includes(location.pathname); - return ( @@ -172,11 +143,14 @@ export default function Header({ + + + - + diff --git a/web/src/components/layout/Icon.tsx b/web/src/components/layout/Icon.tsx index 5530404d00..6b8fe9adbb 100644 --- a/web/src/components/layout/Icon.tsx +++ b/web/src/components/layout/Icon.tsx @@ -38,6 +38,8 @@ import KeyboardArrowDown from "@icons/keyboard_arrow_down.svg?component"; import Globe from "@icons/globe.svg?component"; import HardDrive from "@icons/hard_drive.svg?component"; import Info from "@icons/info.svg?component"; +import Keyboard from "@icons/keyboard.svg?component"; +import Language from "@icons/language.svg?component"; import ListAlt from "@icons/list_alt.svg?component"; import Lock from "@icons/lock.svg?component"; import ManageAccounts from "@icons/manage_accounts.svg?component"; @@ -46,6 +48,7 @@ import MoreVert from "@icons/more_vert.svg?component"; import NetworkWifi from "@icons/network_wifi.svg?component"; import NetworkWifi1Bar from "@icons/network_wifi_1_bar.svg?component"; import NetworkWifi3Bar from "@icons/network_wifi_3_bar.svg?component"; +import Translate from "@icons/translate.svg?component"; import SettingsEthernet from "@icons/settings_ethernet.svg?component"; import Warning from "@icons/warning.svg?component"; import Visibility from "@icons/visibility.svg?component"; @@ -67,7 +70,9 @@ const icons = { globe: Globe, hard_drive: HardDrive, info: Info, + keyboard: Keyboard, keyboard_arrow_down: KeyboardArrowDown, + language: Language, list_alt: ListAlt, lock: Lock, manage_accounts: ManageAccounts, @@ -76,9 +81,10 @@ const icons = { network_wifi: NetworkWifi, network_wifi_1_bar: NetworkWifi1Bar, network_wifi_3_bar: NetworkWifi3Bar, - settings_ethernet: SettingsEthernet, + translate: Translate, visibility: Visibility, visibility_off: VisibilityOff, + settings_ethernet: SettingsEthernet, warning: Warning, wifi: Wifi, wifi_off: WifiOff, diff --git a/web/src/components/network/WifiConnectionForm.test.tsx b/web/src/components/network/WifiConnectionForm.test.tsx index e12a17589e..9c0afd1844 100644 --- a/web/src/components/network/WifiConnectionForm.test.tsx +++ b/web/src/components/network/WifiConnectionForm.test.tsx @@ -22,7 +22,7 @@ import React from "react"; import { screen } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; +import { installerRender } from "~/test-utils"; import WifiConnectionForm from "./WifiConnectionForm"; import { Connection, SecurityProtocols, WifiNetworkStatus, Wireless } from "~/types/network"; @@ -63,13 +63,13 @@ describe("WifiConnectionForm", () => { describe("when rendered for a public network", () => { it("warns the user about connecting to an unprotected network", () => { - plainRender(); + installerRender(, { withL10n: true }); screen.getByText("Warning alert:"); screen.getByText("Not protected network"); }); it("renders only the Connect and Cancel actions", () => { - plainRender(); + installerRender(, { withL10n: true }); expect(screen.queryByRole("combobox", { name: "Security" })).toBeNull(); screen.getByRole("button", { name: "Connect" }); screen.getByRole("button", { name: "Cancel" }); @@ -78,7 +78,9 @@ describe("WifiConnectionForm", () => { describe("when form is submitted", () => { it("replaces form by an informative alert ", async () => { - const { user } = plainRender(); + const { user } = installerRender(, { + withL10n: true, + }); screen.getByRole("form", { name: "Wi-Fi connection form" }); const connectButton = screen.getByRole("button", { name: "Connect" }); await user.click(connectButton); @@ -91,7 +93,9 @@ describe("WifiConnectionForm", () => { describe("for a not configured network", () => { it("triggers a mutation for adding and connecting to the network", async () => { const { settings: _, ...notConfiguredNetwork } = networkMock; - const { user } = plainRender(); + const { user } = installerRender(, { + withL10n: true, + }); const securitySelector = screen.getByRole("combobox", { name: "Security" }); const connectButton = screen.getByText("Connect"); await user.selectOptions(securitySelector, "wpa-psk"); @@ -110,7 +114,7 @@ describe("WifiConnectionForm", () => { describe("for an already configured network", () => { it("triggers a mutation for updating and connecting to the network", async () => { - const { user } = plainRender( + const { user } = installerRender( { }), }} />, + { withL10n: true }, ); const connectButton = screen.getByText("Connect"); const passwordInput = screen.getByLabelText("WPA Password"); diff --git a/web/src/components/product/ProductRegistrationPage.test.tsx b/web/src/components/product/ProductRegistrationPage.test.tsx index e78f39216d..e19a3df0b5 100644 --- a/web/src/components/product/ProductRegistrationPage.test.tsx +++ b/web/src/components/product/ProductRegistrationPage.test.tsx @@ -77,7 +77,7 @@ describe("ProductRegistrationPage", () => { }); it("renders nothing", () => { - const { container } = installerRender(); + const { container } = installerRender(, { withL10n: true }); expect(container).toBeEmptyDOMElement(); }); }); @@ -90,7 +90,7 @@ describe("ProductRegistrationPage", () => { describe("and the static hostname is not set", () => { it("renders a custom alert using the transient hostname", () => { - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Custom alert:"); screen.getByText('The product will be registered with "testing-node" hostname'); @@ -104,7 +104,7 @@ describe("ProductRegistrationPage", () => { }); it("renders a custom alert using the static hostname", () => { - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Custom alert:"); screen.getByText('The product will be registered with "testing-server" hostname'); @@ -113,7 +113,7 @@ describe("ProductRegistrationPage", () => { }); it("allows registering the product with email address", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const registrationCodeInput = screen.getByLabelText("Registration code"); const submitButton = screen.getByRole("button", { name: "Register" }); @@ -139,7 +139,7 @@ describe("ProductRegistrationPage", () => { }); it("allows registering the product without email address", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const registrationCodeInput = screen.getByLabelText("Registration code"); const submitButton = screen.getByRole("button", { name: "Register" }); @@ -157,7 +157,7 @@ describe("ProductRegistrationPage", () => { }); it("renders error when a field is missing", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const registrationCodeInput = screen.getByLabelText("Registration code"); const submitButton = screen.getByRole("button", { name: "Register" }); await user.click(submitButton); @@ -218,7 +218,7 @@ describe("ProductRegistrationPage", () => { }); it("does not render a custom alert about hostname", () => { - installerRender(); + installerRender(, { withL10n: true }); expect(screen.queryByText("Custom alert:")).toBeNull(); expect(screen.queryByText(/hostname/)).toBeNull(); @@ -226,7 +226,7 @@ describe("ProductRegistrationPage", () => { }); it("renders registration information with code partially hidden", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const visibilityCodeToggler = screen.getByRole("button", { name: "Show" }); screen.getByText(/\*?5678/); expect(screen.queryByText("INTERNAL-USE-ONLY-1234-5678")).toBeNull(); @@ -240,7 +240,7 @@ describe("ProductRegistrationPage", () => { }); it("renders available extensions", async () => { - const { container } = installerRender(); + const { container } = installerRender(, { withL10n: true }); // description is displayed screen.getByText(addonInfoMock[0].description); @@ -268,7 +268,7 @@ describe("ProductRegistrationPage", () => { }); it("renders registration information with code partially hidden", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); // the second "Show" button, the first one belongs to the base product registration code const visibilityCodeToggler = screen.getAllByRole("button", { name: "Show" })[1]; diff --git a/web/src/components/questions/LuksActivationQuestion.test.tsx b/web/src/components/questions/LuksActivationQuestion.test.tsx index 9b52dbf221..90023e785e 100644 --- a/web/src/components/questions/LuksActivationQuestion.test.tsx +++ b/web/src/components/questions/LuksActivationQuestion.test.tsx @@ -22,8 +22,10 @@ import React from "react"; import { screen } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; +import { installerRender } from "~/test-utils"; import { AnswerCallback, Question } from "~/types/questions"; +import { InstallationPhase } from "~/types/status"; +import { Product } from "~/types/software"; import LuksActivationQuestion from "~/components/questions/LuksActivationQuestion"; let question: Question; @@ -35,16 +37,67 @@ const questionMock: Question = { defaultOption: "decrypt", data: { attempt: "1" }, }; +const tumbleweed: Product = { + id: "Tumbleweed", + name: "openSUSE Tumbleweed", + icon: "tumbleweed.svg", + description: "Tumbleweed description...", + registration: false, +}; + const answerFn: AnswerCallback = jest.fn(); +const locales = [ + { id: "en_US.UTF-8", name: "English", territory: "United States" }, + { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, +]; + +jest.mock("~/queries/status", () => ({ + useInstallerStatus: () => ({ + phase: InstallationPhase.Config, + isBusy: false, + }), +})); + +jest.mock("~/queries/l10n", () => ({ + ...jest.requireActual("~/queries/l10n"), + useL10n: () => ({ locales, selectedLocale: locales[0] }), +})); + +jest.mock("~/queries/software", () => ({ + ...jest.requireActual("~/queries/software"), + useProduct: () => { + return { + products: [tumbleweed], + selectedProduct: tumbleweed, + }; + }, +})); + +jest.mock("~/context/installerL10n", () => ({ + ...jest.requireActual("~/context/installerL10n"), + useInstallerL10n: () => ({ + keymap: "us", + language: "de-DE", + }), +})); const renderQuestion = () => - plainRender(); + installerRender(, { + withL10n: true, + }); describe("LuksActivationQuestion", () => { beforeEach(() => { question = { ...questionMock }; }); + it("allows opening the installer keymap settings", async () => { + const { user } = renderQuestion(); + const changeKeymapButton = screen.getByRole("button", { name: "Change keyboard layout" }); + await user.click(changeKeymapButton); + screen.getByRole("dialog", { name: "Change keyboard" }); + }); + it("renders the question text", async () => { renderQuestion(); diff --git a/web/src/components/questions/LuksActivationQuestion.tsx b/web/src/components/questions/LuksActivationQuestion.tsx index d576aeeb1f..efd2eaaf91 100644 --- a/web/src/components/questions/LuksActivationQuestion.tsx +++ b/web/src/components/questions/LuksActivationQuestion.tsx @@ -22,8 +22,7 @@ import React, { useState } from "react"; import { Alert as PFAlert, Content, Form, FormGroup, Stack } from "@patternfly/react-core"; -import { Icon } from "~/components/layout"; -import { PasswordInput, Popup } from "~/components/core"; +import { InstallerOptions, PasswordInput, Popup } from "~/components/core"; import QuestionActions from "~/components/questions/QuestionActions"; import { _ } from "~/i18n"; @@ -68,7 +67,8 @@ export default function LuksActivationQuestion({ question, answerCallback }) { isOpen title={_("Encrypted Device")} aria-label={_("Question")} - titleIconVariant={() => } + elementToFocus="#luks-password" + titleAddon={} > @@ -77,7 +77,6 @@ export default function LuksActivationQuestion({ question, answerCallback }) { {/* TRANSLATORS: field label */} setPassword(value)} diff --git a/web/src/components/questions/QuestionWithPassword.test.tsx b/web/src/components/questions/QuestionWithPassword.test.tsx index 6f495eac1c..67a8f5f0be 100644 --- a/web/src/components/questions/QuestionWithPassword.test.tsx +++ b/web/src/components/questions/QuestionWithPassword.test.tsx @@ -22,8 +22,10 @@ import React from "react"; import { screen } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; +import { installerRender } from "~/test-utils"; import { Question } from "~/types/questions"; +import { Product } from "~/types/software"; +import { InstallationPhase } from "~/types/status"; import QuestionWithPassword from "~/components/questions/QuestionWithPassword"; const answerFn = jest.fn(); @@ -35,10 +37,63 @@ const question: Question = { defaultOption: "cancel", }; +const tumbleweed: Product = { + id: "Tumbleweed", + name: "openSUSE Tumbleweed", + icon: "tumbleweed.svg", + description: "Tumbleweed description...", + registration: false, +}; + +const locales = [ + { id: "en_US.UTF-8", name: "English", territory: "United States" }, + { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, +]; + +jest.mock("~/queries/status", () => ({ + useInstallerStatus: () => ({ + phase: InstallationPhase.Config, + isBusy: false, + }), +})); + +jest.mock("~/queries/l10n", () => ({ + ...jest.requireActual("~/queries/l10n"), + useL10n: () => ({ locales, selectedLocale: locales[0] }), +})); + +jest.mock("~/queries/software", () => ({ + ...jest.requireActual("~/queries/software"), + useProduct: () => { + return { + products: [tumbleweed], + selectedProduct: tumbleweed, + }; + }, +})); + +jest.mock("~/context/installerL10n", () => ({ + ...jest.requireActual("~/context/installerL10n"), + useInstallerL10n: () => ({ + keymap: "us", + language: "de-DE", + }), + useL10n: jest.fn(), +})); + const renderQuestion = () => - plainRender(); + installerRender(, { + withL10n: true, + }); describe("QuestionWithPassword", () => { + it("allows opening the installer keymap settings", async () => { + const { user } = renderQuestion(); + const changeKeymapButton = screen.getByRole("button", { name: "Change keyboard layout" }); + await user.click(changeKeymapButton); + screen.getByRole("dialog", { name: "Change keyboard" }); + }); + it("renders the question text", () => { renderQuestion(); diff --git a/web/src/components/questions/QuestionWithPassword.tsx b/web/src/components/questions/QuestionWithPassword.tsx index 4e627adc55..754d35b8f2 100644 --- a/web/src/components/questions/QuestionWithPassword.tsx +++ b/web/src/components/questions/QuestionWithPassword.tsx @@ -23,7 +23,7 @@ import React, { useState } from "react"; import { Content, Form, FormGroup, Stack } from "@patternfly/react-core"; import { Icon } from "~/components/layout"; -import { PasswordInput, Popup } from "~/components/core"; +import { InstallerOptions, PasswordInput, Popup } from "~/components/core"; import { AnswerCallback, Question } from "~/types/questions"; import QuestionActions from "~/components/questions/QuestionActions"; import { _ } from "~/i18n"; @@ -51,7 +51,12 @@ export default function QuestionWithPassword({ }; return ( - }> + } + titleAddon={} + > {question.text}
diff --git a/web/src/components/storage/EncryptionSettingsPage.test.tsx b/web/src/components/storage/EncryptionSettingsPage.test.tsx index 9a32663447..1fb39cb060 100644 --- a/web/src/components/storage/EncryptionSettingsPage.test.tsx +++ b/web/src/components/storage/EncryptionSettingsPage.test.tsx @@ -77,7 +77,7 @@ describe("EncryptionSettingsPage", () => { }); it("allows enabling the encryption", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const encryptionCheckbox = screen.getByRole("checkbox", { name: "Encrypt the system" }); expect(encryptionCheckbox).not.toBeChecked(); await user.click(encryptionCheckbox); @@ -97,7 +97,7 @@ describe("EncryptionSettingsPage", () => { }); it("allows disabling the encryption", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const encryptionCheckbox = screen.getByRole("checkbox", { name: "Encrypt the system" }); expect(encryptionCheckbox).toBeChecked(); await user.click(encryptionCheckbox); @@ -114,7 +114,7 @@ describe("EncryptionSettingsPage", () => { }); it("allows disabling TPM", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const tpmCheckbox = screen.getByRole("checkbox", { name: /Use.*TPM/ }); const acceptButton = screen.getByRole("button", { name: "Accept" }); expect(tpmCheckbox).toBeChecked(); @@ -131,7 +131,7 @@ describe("EncryptionSettingsPage", () => { }); it("does not offer TPM", () => { - installerRender(); + installerRender(, { withL10n: true }); expect(screen.queryByRole("checkbox", { name: /Use.*TPM/ })).toBeNull(); }); }); diff --git a/web/src/components/storage/iscsi/TargetsSection.test.tsx b/web/src/components/storage/iscsi/TargetsSection.test.tsx index 02c157d874..d20e197491 100644 --- a/web/src/components/storage/iscsi/TargetsSection.test.tsx +++ b/web/src/components/storage/iscsi/TargetsSection.test.tsx @@ -22,7 +22,7 @@ import React from "react"; import { screen, waitFor, within } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; +import { installerRender } from "~/test-utils"; import TargetsSection from "./TargetsSection"; import { deleteNode, discover, login, logout } from "~/api/storage/iscsi"; @@ -71,7 +71,7 @@ describe("TargetsSection", () => { describe("allows discovering the node", () => { it("asks for discover info and closes the dialog on success", async () => { - const { user } = plainRender(); + const { user } = installerRender(, { withL10n: true }); const button = await screen.findByRole("button", { name: "Discover iSCSI targets" }); await user.click(button); @@ -112,7 +112,7 @@ describe("TargetsSection", () => { }); it("allows logging into disconnected targets", async () => { - const { user } = plainRender(); + const { user } = installerRender(, { withL10n: true }); const row = await screen.findByRole("row", { name: /Disconnected/ }); const actionsButton = await within(row).findByRole("button", { name: "Actions" }); @@ -147,7 +147,7 @@ describe("TargetsSection", () => { }); it("allows logging out connected targets", async () => { - const { user } = plainRender(); + const { user } = installerRender(, { withL10n: true }); const row = await screen.findByRole("row", { name: /Connected/ }); const actionsButton = await within(row).findByRole("button", { name: "Actions" }); @@ -161,7 +161,7 @@ describe("TargetsSection", () => { }); it("allows deleting a disconnected target", async () => { - const { user } = plainRender(); + const { user } = installerRender(, { withL10n: true }); const row = await screen.findByRole("row", { name: /Disconnected/ }); const actionsButton = await within(row).findByRole("button", { name: "Actions" }); diff --git a/web/src/components/users/FirstUserForm.test.tsx b/web/src/components/users/FirstUserForm.test.tsx index 5246e0f96b..c42f5ff23d 100644 --- a/web/src/components/users/FirstUserForm.test.tsx +++ b/web/src/components/users/FirstUserForm.test.tsx @@ -50,7 +50,7 @@ jest.mock("~/queries/users", () => ({ describe("FirstUserForm", () => { it("allows using suggested username", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const fullNameInput = screen.getByRole("textbox", { name: "Full name" }); const userNameInput = screen.getByRole("textbox", { name: "Username" }); await user.type(fullNameInput, "Gecko Giggles"); @@ -77,7 +77,7 @@ describe("FirstUserForm", () => { }); it("renders the form in 'create' mode", () => { - installerRender(); + installerRender(, { withL10n: true }); screen.getByRole("heading", { name: "Create user" }); screen.getByRole("textbox", { name: "Full name" }); @@ -92,7 +92,7 @@ describe("FirstUserForm", () => { }); it("allows defining the user when all data is provided", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const fullname = screen.getByRole("textbox", { name: "Full name" }); const username = screen.getByRole("textbox", { name: "Username" }); @@ -116,7 +116,7 @@ describe("FirstUserForm", () => { }); it("warning about missing data", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const fullname = screen.getByRole("textbox", { name: "Full name" }); const acceptButton = screen.getByRole("button", { name: "Accept" }); @@ -130,7 +130,7 @@ describe("FirstUserForm", () => { it("renders errors from the server, if any", async () => { mockFirstUserMutation.mockRejectedValue({ response: { data: "Username not valid" } }); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const fullname = screen.getByRole("textbox", { name: "Full name" }); const username = screen.getByRole("textbox", { name: "Username" }); @@ -158,7 +158,7 @@ describe("FirstUserForm", () => { }); it("renders the form in 'edit' mode", () => { - installerRender(); + installerRender(, { withL10n: true }); screen.getByRole("heading", { name: "Edit user" }); const fullNameInput = screen.getByRole("textbox", { name: "Full name" }); @@ -174,7 +174,7 @@ describe("FirstUserForm", () => { }); it("allows editing user definition without changing the password", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const fullname = screen.getByRole("textbox", { name: "Full name" }); const username = screen.getByRole("textbox", { name: "Username" }); @@ -194,7 +194,7 @@ describe("FirstUserForm", () => { }); it("allows editing full user definition", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const fullname = screen.getByRole("textbox", { name: "Full name" }); const username = screen.getByRole("textbox", { name: "Username" }); @@ -228,7 +228,7 @@ describe("FirstUserForm", () => { }); it("allows preserving it", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const acceptButton = screen.getByRole("button", { name: "Accept" }); screen.getByText("Using a hashed password."); await user.click(acceptButton); @@ -238,7 +238,7 @@ describe("FirstUserForm", () => { }); it("allows using a plain password instead", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const acceptButton = screen.getByRole("button", { name: "Accept" }); screen.getByText("Using a hashed password."); expect(screen.queryByText(mockPassword)).not.toBeInTheDocument(); diff --git a/web/src/components/users/RootUserForm.test.tsx b/web/src/components/users/RootUserForm.test.tsx index 28f163d8db..9c7ea7c905 100644 --- a/web/src/components/users/RootUserForm.test.tsx +++ b/web/src/components/users/RootUserForm.test.tsx @@ -54,7 +54,7 @@ describe("RootUserForm", () => { }); it("allows setting/editing a password", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const acceptButton = screen.getByRole("button", { name: "Accept" }); const passwordInput = screen.getByLabelText("Password"); const passwordConfirmationInput = screen.getByLabelText("Password confirmation"); @@ -69,7 +69,7 @@ describe("RootUserForm", () => { }); it("does not allow setting an empty password", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const acceptButton = screen.getByRole("button", { name: "Accept" }); const passwordInput = screen.getByLabelText("Password"); const passwordConfirmationInput = screen.getByLabelText("Password confirmation"); @@ -84,7 +84,7 @@ describe("RootUserForm", () => { }); it("renders password validation errors, if any", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const acceptButton = screen.getByRole("button", { name: "Accept" }); const passwordInput = screen.getByLabelText("Password"); const passwordConfirmationInput = screen.getByLabelText("Password confirmation"); @@ -97,7 +97,7 @@ describe("RootUserForm", () => { }); it("allows clearing the password", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const passwordToggle = screen.getByRole("checkbox", { name: "Use password" }); const acceptButton = screen.getByRole("button", { name: "Accept" }); expect(passwordToggle).toBeChecked(); @@ -110,7 +110,7 @@ describe("RootUserForm", () => { }); it("allows setting a public SSH Key ", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const sshPublicKeyToggle = screen.getByRole("checkbox", { name: "Use public SSH Key" }); const acceptButton = screen.getByRole("button", { name: "Accept" }); await user.click(sshPublicKeyToggle); @@ -125,7 +125,7 @@ describe("RootUserForm", () => { }); it("does not allow setting an empty public SSH Key", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const sshPublicKeyToggle = screen.getByRole("checkbox", { name: "Use public SSH Key" }); const acceptButton = screen.getByRole("button", { name: "Accept" }); await user.click(sshPublicKeyToggle); @@ -138,7 +138,7 @@ describe("RootUserForm", () => { it("allows clearing the public SSH Key", async () => { mockPublicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+ test@example"; - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const sshPublicKeyToggle = screen.getByRole("checkbox", { name: "Use public SSH Key" }); const acceptButton = screen.getByRole("button", { name: "Accept" }); expect(sshPublicKeyToggle).toBeChecked(); @@ -157,7 +157,7 @@ describe("RootUserForm", () => { }); it("allows preserving it", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const passwordToggle = screen.getByRole("checkbox", { name: "Use password" }); const acceptButton = screen.getByRole("button", { name: "Accept" }); expect(passwordToggle).toBeChecked(); @@ -169,7 +169,7 @@ describe("RootUserForm", () => { }); it("allows discarding it", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const passwordToggle = screen.getByRole("checkbox", { name: "Use password" }); const acceptButton = screen.getByRole("button", { name: "Accept" }); expect(passwordToggle).toBeChecked(); @@ -182,7 +182,7 @@ describe("RootUserForm", () => { }); it("allows using a plain password instead", async () => { - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const acceptButton = screen.getByRole("button", { name: "Accept" }); const changeToPlainButton = screen.getByRole("button", { name: "Change" }); await user.click(changeToPlainButton); diff --git a/web/src/hooks/use-key-lock.test.ts b/web/src/hooks/use-key-lock.test.ts new file mode 100644 index 0000000000..563c70d03c --- /dev/null +++ b/web/src/hooks/use-key-lock.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { fireEvent, renderHook } from "@testing-library/react"; +import { useKeyLock } from "./use-key-lock"; + +describe("useKeyLock", () => { + it("detects CapsLock changes", () => { + const { result } = renderHook(() => useKeyLock("CapsLock")); + expect(result.current).toBe(false); + + // Does not change when triggering different key + fireEvent.keyDown(window, { key: "NumLock" }); + expect(result.current).toBe(false); + + fireEvent.keyDown(window, { key: "CapsLock" }); + expect(result.current).toBe(true); + + fireEvent.keyDown(window, { key: "CapsLock" }); + expect(result.current).toBe(false); + }); + + it("detects NumLock changes", () => { + const { result } = renderHook(() => useKeyLock("NumLock")); + expect(result.current).toBe(false); + + // Does not change when triggering different key + fireEvent.keyDown(window, { key: "CapsLock" }); + expect(result.current).toBe(false); + + fireEvent.keyDown(window, { key: "NumLock" }); + expect(result.current).toBe(true); + + fireEvent.keyDown(window, { key: "NumLock" }); + expect(result.current).toBe(false); + }); + + it("detects ScrollLock changes", () => { + const { result } = renderHook(() => useKeyLock("ScrollLock")); + expect(result.current).toBe(false); + + // Does not change when triggering different key + fireEvent.keyDown(window, { key: "CapsLock" }); + expect(result.current).toBe(false); + + fireEvent.keyDown(window, { key: "ScrollLock" }); + expect(result.current).toBe(true); + + fireEvent.keyDown(window, { key: "ScrollLock" }); + expect(result.current).toBe(false); + }); +}); diff --git a/web/src/hooks/use-key-lock.ts b/web/src/hooks/use-key-lock.ts new file mode 100644 index 0000000000..0489dd5d20 --- /dev/null +++ b/web/src/hooks/use-key-lock.ts @@ -0,0 +1,30 @@ +// Borrowed from https://pietrobondioli.com.br/articles/how-to-get-keylock-state-on-react +import { useCallback, useEffect, useState } from "react"; + +type KeyLock = "CapsLock" | "NumLock" | "ScrollLock"; + +export const useKeyLock = (targetKey: KeyLock) => { + const [isKeyLocked, setIsKeyLocked] = useState(false); + + const checkKeyState = useCallback( + (event: KeyboardEvent) => { + if (event.key !== targetKey) { + setIsKeyLocked(event.getModifierState(targetKey)); + return; + } + + setIsKeyLocked(!isKeyLocked); + }, + [targetKey, isKeyLocked], + ); + + useEffect(() => { + window.addEventListener("keydown", checkKeyState); + + return () => { + window.removeEventListener("keydown", checkKeyState); + }; + }, [targetKey, checkKeyState]); + + return isKeyLocked; +};