diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 32da03c73d..fdb1e0d41d 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Wed Feb 11 14:24:03 UTC 2026 - Imobach Gonzalez Sosa + +- Honor the language from the backend (gh#agama-project/agama#3142). + ------------------------------------------------------------------- Wed Feb 11 13:35:13 UTC 2026 - Ladislav Slezák diff --git a/web/src/components/product/LicenseDialog.test.tsx b/web/src/components/product/LicenseDialog.test.tsx index 073ebe8918..18a481d7f8 100644 --- a/web/src/components/product/LicenseDialog.test.tsx +++ b/web/src/components/product/LicenseDialog.test.tsx @@ -110,7 +110,7 @@ describe("LicenseDialog", () => { await waitFor(() => { expect(mockFetchLicense).toHaveBeenCalledWith(sle.license, mockUILanguage); screen.getByText("El contenido de la licencia"); - screen.getByText("This license is not available in Deutsch."); + screen.getByText("Diese Lizenz ist in Deutsch nicht verfügbar."); }); }); }); @@ -119,7 +119,7 @@ describe("LicenseDialog", () => { const { user } = installerRender(, { withL10n: true, }); - const closeButton = screen.getByRole("button", { name: "Close" }); + const closeButton = screen.getByRole("button", { name: "Schließen" }); await user.click(closeButton); expect(onCloseFn).toHaveBeenCalled(); }); diff --git a/web/src/components/product/ProductSelectionPage.test.tsx b/web/src/components/product/ProductSelectionPage.test.tsx index e7693fd988..7f6e598e4e 100644 --- a/web/src/components/product/ProductSelectionPage.test.tsx +++ b/web/src/components/product/ProductSelectionPage.test.tsx @@ -104,7 +104,7 @@ describe("ProductSelectionPage", () => { // No product selected yet mockProduct(undefined); - const { rerender } = installerRender(); + const { rerender } = installerRender(, { withL10n: true }); screen.getByRole("radio", { name: tumbleweed.name }); screen.getByRole("radio", { name: microOs.name }); screen.getByRole("radio", { name: productWithModes.name }); @@ -132,7 +132,7 @@ describe("ProductSelectionPage", () => { // test ensures the selected option remains checked across re-renders with new // object references. it("keeps product selection across re-renders", async () => { - const { user, rerender } = installerRender(); + const { user, rerender } = installerRender(, { withL10n: true }); const microOsOption = screen.getByRole("radio", { name: microOs.name }); expect(microOsOption).not.toBeChecked(); await user.click(microOsOption); @@ -151,7 +151,7 @@ describe("ProductSelectionPage", () => { it("force license acceptance for products with license", async () => { mockProduct(undefined); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); expect(screen.queryByRole("checkbox", { name: /I have read and accept/ })).toBeNull(); const selectButton = screen.getByRole("button", { name: "Select" }); const microOsOption = screen.getByRole("radio", { name: microOs.name }); @@ -173,7 +173,7 @@ describe("ProductSelectionPage", () => { products: [productWithLicense1, productWithLicense2], }); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); // Select first product and accept license const product1Option = screen.getByRole("radio", { name: "Product 1" }); @@ -197,25 +197,25 @@ describe("ProductSelectionPage", () => { repositories: [], registration: { code: "INTERNAL-USE-ONLY-1234-5678", addons: [] }, }); - installerRender(); + installerRender(, { withL10n: true }); await screen.findByText("Navigating to /"); }); it("renders the Cancel button when a product is already seelected ", () => { mockProduct(microOs); - installerRender(); + installerRender(, { withL10n: true }); screen.getByRole("link", { name: "Cancel" }); }); it("does not render the Cancel button if product no selected yet", () => { mockProduct(undefined); - installerRender(); + installerRender(, { withL10n: true }); expect(screen.queryByRole("link", { name: "Cancel" })).toBeNull(); }); it("triggers the product selection when user select a product and click submission button", async () => { mockProduct(undefined); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const productOption = screen.getByRole("radio", { name: tumbleweed.name }); const selectButton = screen.getByRole("button", { name: "Select" }); await user.click(productOption); @@ -225,7 +225,7 @@ describe("ProductSelectionPage", () => { it("does not trigger the product selection if user selects a product but clicks o cancel button", async () => { mockProduct(microOs); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const productOption = screen.getByRole("radio", { name: tumbleweed.name }); const cancel = screen.getByRole("link", { name: "Cancel" }); expect(cancel).toHaveAttribute("href", ROOT.overview); @@ -237,7 +237,7 @@ describe("ProductSelectionPage", () => { it.todo("make navigation test work"); it.skip("navigates to root after successful product selection", async () => { mockProduct(undefined); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const tumbleweedOption = screen.getByRole("radio", { name: tumbleweed.name }); await user.click(tumbleweedOption); @@ -257,7 +257,7 @@ describe("ProductSelectionPage", () => { it("renders mode options when product has modes", async () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const productOption = screen.getByRole("radio", { name: productWithModes.name }); await user.click(productOption); @@ -270,7 +270,7 @@ describe("ProductSelectionPage", () => { mockProduct(productWithModes); mockProductConfig({ id: productWithModes.id, mode: "standard" }); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const productOption = screen.getByRole("radio", { name: productWithModes.name }); await user.click(productOption); @@ -282,7 +282,7 @@ describe("ProductSelectionPage", () => { it("allows selecting a mode", async () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const productOption = screen.getByRole("radio", { name: productWithModes.name }); await user.click(productOption); @@ -302,7 +302,7 @@ describe("ProductSelectionPage", () => { it("submits product with selected mode", async () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const productOption = screen.getByRole("radio", { name: productWithModes.name }); await user.click(productOption); @@ -321,7 +321,7 @@ describe("ProductSelectionPage", () => { it("resets mode selection when switching to a product without modes", async () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [productWithModes, tumbleweed] }); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); // Select product with modes and choose a mode const slesOption = screen.getByRole("radio", { name: productWithModes.name }); @@ -341,7 +341,7 @@ describe("ProductSelectionPage", () => { it("disables submit button when product with modes has no mode selected", async () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const productOption = screen.getByRole("radio", { name: productWithModes.name }); await user.click(productOption); @@ -353,7 +353,7 @@ describe("ProductSelectionPage", () => { it("enables submit button when mode is selected", async () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const productOption = screen.getByRole("radio", { name: productWithModes.name }); await user.click(productOption); @@ -369,7 +369,7 @@ describe("ProductSelectionPage", () => { it("includes mode name in submit button label", async () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const productOption = screen.getByRole("radio", { name: productWithModes.name }); await user.click(productOption); @@ -383,7 +383,7 @@ describe("ProductSelectionPage", () => { it("shows only product name when no mode selected", async () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const productOption = screen.getByRole("radio", { name: productWithModes.name }); await user.click(productOption); @@ -397,7 +397,7 @@ describe("ProductSelectionPage", () => { it("shows warning when product with modes is selected but no mode chosen", async () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const productOption = screen.getByRole("radio", { name: productWithModes.name }); await user.click(productOption); @@ -408,7 +408,7 @@ describe("ProductSelectionPage", () => { it("hides warning when mode is selected", async () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const productOption = screen.getByRole("radio", { name: productWithModes.name }); await user.click(productOption); @@ -423,7 +423,7 @@ describe("ProductSelectionPage", () => { it("does not show mode warning for products without modes", async () => { mockProduct(undefined); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const productOption = screen.getByRole("radio", { name: tumbleweed.name }); await user.click(productOption); @@ -438,7 +438,7 @@ describe("ProductSelectionPage", () => { it("renders 'Select a mode' when product has modes and no product selected", () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByRole("heading", { name: "Select a mode" }); }); @@ -446,7 +446,7 @@ describe("ProductSelectionPage", () => { it("renders 'Change mode' when product with modes is already selected", () => { mockProduct(productWithModes); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByRole("heading", { name: "Change mode" }); }); @@ -454,7 +454,7 @@ describe("ProductSelectionPage", () => { it("renders 'Select a product' when single product has no modes", () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [tumbleweed] }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByRole("heading", { name: "Select a product" }); }); @@ -464,7 +464,7 @@ describe("ProductSelectionPage", () => { it("renders 'Select a product' when no product selected", () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [tumbleweed, microOs] }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByRole("heading", { name: "Select a product" }); }); @@ -472,7 +472,7 @@ describe("ProductSelectionPage", () => { it("renders 'Change product' when switching from product without modes", () => { mockProduct(tumbleweed); mockUseSystemFn.mockReturnValue({ products: [tumbleweed, microOs] }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByRole("heading", { name: "Change product" }); }); @@ -480,7 +480,7 @@ describe("ProductSelectionPage", () => { it("renders 'Change product or mode' when switching from product with modes", () => { mockProduct(productWithModes); mockUseSystemFn.mockReturnValue({ products: [productWithModes, tumbleweed] }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByRole("heading", { name: "Change product or mode" }); }); @@ -492,7 +492,7 @@ describe("ProductSelectionPage", () => { it("renders mode selection intro when product has modes", () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Select a mode and confirm your choice."); }); @@ -500,7 +500,7 @@ describe("ProductSelectionPage", () => { it("renders confirmation intro when product has no modes", () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [tumbleweed] }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Confirm the product selection."); }); @@ -510,7 +510,7 @@ describe("ProductSelectionPage", () => { it("renders singular form with two products available but one already selected", () => { mockProduct(tumbleweed); mockUseSystemFn.mockReturnValue({ products: [tumbleweed, microOs] }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Select a product and confirm your choice."); }); @@ -518,7 +518,7 @@ describe("ProductSelectionPage", () => { it("renders plural form when multiple products available for initial selection", () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [tumbleweed, microOs] }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Select a product and confirm your choice at the end of the list."); }); @@ -528,7 +528,7 @@ describe("ProductSelectionPage", () => { mockUseSystemFn.mockReturnValue({ products: [tumbleweed, microOs, productWithModes], }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Select a product and confirm your choice at the end of the list."); }); @@ -538,7 +538,7 @@ describe("ProductSelectionPage", () => { mockUseSystemFn.mockReturnValue({ products: [tumbleweed, microOs, productWithModes], }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Select a product and confirm your choice at the end of the list."); }); @@ -550,7 +550,7 @@ describe("ProductSelectionPage", () => { it("renders 'Choose a mode' when product has modes and no product selected", () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Choose a mode"); }); @@ -558,7 +558,7 @@ describe("ProductSelectionPage", () => { it("renders 'Switch to a different mode' when product is already selected", () => { mockProduct(productWithModes); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Switch to a different mode"); }); @@ -569,7 +569,7 @@ describe("ProductSelectionPage", () => { it("renders 'Choose a product' when single product has no modes", () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [tumbleweed] }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Choose a product"); }); @@ -579,7 +579,7 @@ describe("ProductSelectionPage", () => { it("renders plural form when multiple products available", () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [tumbleweed, microOs] }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Choose from 2 available products"); }); @@ -589,7 +589,7 @@ describe("ProductSelectionPage", () => { it("renders singular form when only one other product available", () => { mockProduct(tumbleweed); mockUseSystemFn.mockReturnValue({ products: [tumbleweed, microOs] }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Switch to another product"); }); @@ -599,7 +599,7 @@ describe("ProductSelectionPage", () => { mockUseSystemFn.mockReturnValue({ products: [tumbleweed, microOs, productWithModes], }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Switch to one of 2 available products"); }); @@ -611,7 +611,7 @@ describe("ProductSelectionPage", () => { mockUseSystemFn.mockReturnValue({ products: [productWithModes, tumbleweed], }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Switch to a different mode or another product"); }); @@ -621,7 +621,7 @@ describe("ProductSelectionPage", () => { mockUseSystemFn.mockReturnValue({ products: [productWithModes, tumbleweed, microOs], }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Switch to a different mode or to one of 2 available products"); }); @@ -631,7 +631,7 @@ describe("ProductSelectionPage", () => { describe("ProductFormSubmitLabel", () => { it("renders 'Change' or 'Change to %product.name' when changing from one product to another", async () => { mockProduct(microOs); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); screen.getByRole("button", { name: "Change" }); const tumbleweedOption = screen.getByRole("radio", { name: tumbleweed.name }); @@ -641,7 +641,7 @@ describe("ProductSelectionPage", () => { it("renders 'Select' or 'Select %product.name' during initial selection", async () => { mockProduct(undefined); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); screen.getByRole("button", { name: "Select" }); const tumbleweedOption = screen.getByRole("radio", { name: tumbleweed.name }); @@ -653,14 +653,14 @@ describe("ProductSelectionPage", () => { describe("ProductFormSubmitLabelHelp", () => { it("renders warning when no product is selected", () => { mockProduct(undefined); - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("Select a product to continue."); }); it("renders warning when license is not accepted", async () => { mockProduct(undefined); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const microOsOption = screen.getByRole("radio", { name: microOs.name }); await user.click(microOsOption); @@ -669,7 +669,7 @@ describe("ProductSelectionPage", () => { it("hides helper text when product is selected and license is accepted", async () => { mockProduct(undefined); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const microOsOption = screen.getByRole("radio", { name: microOs.name }); await user.click(microOsOption); @@ -685,7 +685,7 @@ describe("ProductSelectionPage", () => { describe("CurrentProductInfo", () => { it("renders nothing when no product has been set yet", () => { mockProduct(undefined); - installerRender(); + installerRender(, { withL10n: true }); expect(screen.queryByRole("heading", { level: 2, name: "Current selection" })).toBeNull(); }); @@ -693,7 +693,7 @@ describe("ProductSelectionPage", () => { it("displays mode information when product has a selected mode", () => { mockProductConfig({ id: productWithModes.id, mode: "standard" }); mockProduct(productWithModes); - installerRender(); + installerRender(, { withL10n: true }); const sectionHeading = screen.getByRole("heading", { level: 2, name: "Current selection" }); const section = sectionHeading.closest("section"); @@ -709,7 +709,7 @@ describe("ProductSelectionPage", () => { it("does not display mode information for products without modes", () => { mockProduct(tumbleweed); - installerRender(); + installerRender(, { withL10n: true }); const sectionHeading = screen.getByRole("heading", { level: 2, name: "Current selection" }); const section = sectionHeading.closest("section"); @@ -734,7 +734,7 @@ describe("ProductSelectionPage", () => { // navigates to the overview. it("renders nothing when new product is set (form was submitted)", async () => { mockProduct(microOs); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); screen.getByRole("heading", { level: 2, name: "Current selection" }); const tumbleweedOption = screen.getByRole("radio", { name: tumbleweed.name }); @@ -748,7 +748,7 @@ describe("ProductSelectionPage", () => { it("renders current product information when changing products", () => { mockProduct(microOs); - installerRender(); + installerRender(, { withL10n: true }); const sectionHeading = screen.getByRole("heading", { level: 2, name: "Current selection" }); const section = sectionHeading.closest("section"); @@ -758,7 +758,7 @@ describe("ProductSelectionPage", () => { it("renders view license button for products with license", () => { mockProduct(microOs); - installerRender(); + installerRender(, { withL10n: true }); const sectionHeading = screen.getByRole("heading", { level: 2, name: "Current selection" }); const section = sectionHeading.closest("section"); @@ -767,7 +767,7 @@ describe("ProductSelectionPage", () => { it("does not render view license button for products without license", () => { mockProduct(tumbleweed); - installerRender(); + installerRender(, { withL10n: true }); const sectionHeading = screen.getByRole("heading", { level: 2, name: "Current selection" }); const section = sectionHeading.closest("section"); @@ -778,7 +778,7 @@ describe("ProductSelectionPage", () => { it("does not render when no product is selected", () => { mockProduct(undefined); - installerRender(); + installerRender(, { withL10n: true }); expect(screen.queryByText("Current selection")).not.toBeInTheDocument(); }); @@ -787,7 +787,7 @@ describe("ProductSelectionPage", () => { describe("LicenseButton", () => { it("opens license dialog", async () => { mockProduct(microOs); - const { user } = installerRender(); + const { user } = installerRender(, { withL10n: true }); const viewLicenseButton = screen.getByRole("button", { name: "View license" }); await user.click(viewLicenseButton); @@ -799,7 +799,7 @@ describe("ProductSelectionPage", () => { it("displays license requirement label for products with licenses", () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [microOs] }); - const { rerender } = installerRender(); + const { rerender } = installerRender(, { withL10n: true }); screen.getByText("License acceptance required"); mockUseSystemFn.mockReturnValue({ products: [tumbleweed] }); @@ -810,7 +810,7 @@ describe("ProductSelectionPage", () => { it("displays modes label for products with modes (none selected yet)", () => { mockProduct(undefined); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - const { rerender } = installerRender(); + const { rerender } = installerRender(, { withL10n: true }); screen.getByText("2 modes available"); mockUseSystemFn.mockReturnValue({ products: [tumbleweed] }); @@ -822,7 +822,7 @@ describe("ProductSelectionPage", () => { mockProduct(productWithModes); mockProductConfig({ id: productWithModes.id, mode: "standard" }); mockUseSystemFn.mockReturnValue({ products: [productWithModes] }); - installerRender(); + installerRender(, { withL10n: true }); screen.getByText("1 other mode available"); }); diff --git a/web/src/components/product/ProductSelectionPage.tsx b/web/src/components/product/ProductSelectionPage.tsx index 0bd60842a1..e4e417ccd0 100644 --- a/web/src/components/product/ProductSelectionPage.tsx +++ b/web/src/components/product/ProductSelectionPage.tsx @@ -55,7 +55,6 @@ import { Link, Page, SubtleContent } from "~/components/core"; import ProductLogo from "~/components/product/ProductLogo"; import LicenseDialog from "~/components/product/LicenseDialog"; import Text from "~/components/core/Text"; -import agama from "~/agama"; import { putConfig } from "~/api"; import { useProduct, useProductInfo } from "~/hooks/model/config/product"; import { useSystem } from "~/hooks/model/system"; @@ -65,6 +64,7 @@ import { Mode, Product } from "~/model/system"; import { n_, _ } from "~/i18n"; import pfTextStyles from "@patternfly/react-styles/css/utilities/Text/text"; +import { useInstallerL10n } from "~/context/installerL10n"; /** * Props for ProductFormProductOption component @@ -98,8 +98,8 @@ const ProductFormProductOption = ({ onChange, onModeChange, }: ProductFormProductOptionProps) => { + const { loadedLanguage: currentLocale } = useInstallerL10n(); const detailsId = `${product.id}-details`; - const currentLocale = agama.language.replace("-", "_"); const translatedDescription = product.translations?.description[currentLocale] || product.description; @@ -569,14 +569,16 @@ type CurrentProductInfoProps = { * Shows product name, description, and a link to view the license if applicable. */ const CurrentProductInfo = ({ product, modeId }: CurrentProductInfoProps) => { + const { loadedLanguage: currentLocale } = useInstallerL10n(); if (!product) return; - let mode; - let translatedModeName; - let translatedModeDescription; - if (modeId) { - const currentLocale = agama.language.replace("-", "_"); + const translatedDescription = + product.translations?.description[currentLocale] || product.description; + let mode: Mode; + let translatedModeName: string; + let translatedModeDescription: string; + if (modeId) { mode = product.modes.find((m) => m.id === modeId); translatedModeName = product.translations?.mode?.[modeId]?.name[currentLocale] || mode?.name; translatedModeDescription = @@ -592,7 +594,7 @@ const CurrentProductInfo = ({ product, modeId }: CurrentProductInfoProps) => { {product.name} - {product.description} + {translatedDescription} {mode && ( <> diff --git a/web/src/context/installerL10n.test.tsx b/web/src/context/installerL10n.test.tsx index 43958666a2..90c3f3fb41 100644 --- a/web/src/context/installerL10n.test.tsx +++ b/web/src/context/installerL10n.test.tsx @@ -22,10 +22,8 @@ import React from "react"; import { render, screen, waitFor } from "@testing-library/react"; -import { InstallerL10nProvider } from "~/context/installerL10n"; +import { InstallerL10nProvider, useInstallerL10n } from "~/context/installerL10n"; import { InstallerClientProvider } from "./installer"; -import * as utils from "~/utils"; -import { noop } from "radashi"; const mockUseSystemFn = jest.fn(); const mockConfigureL10nFn = jest.fn(); @@ -64,6 +62,7 @@ jest.mock("~/languages.json", () => ({ // Helper component that displays a translated message depending on the // agamaLang value. const TranslatedContent = () => { + const { language } = useInstallerL10n(); const text = { "cs-CZ": "ahoj", "en-US": "hello", @@ -71,173 +70,25 @@ const TranslatedContent = () => { "es-AR": "hola!", }; - const regexp = /agamaLang=([^;]+)/; - const found = document.cookie.match(regexp); - if (!found) return <>{text["en-US"]}; - - const [, lang] = found; - return <>{text[lang]}; + return <>{text[language]}; }; describe("InstallerL10nProvider", () => { beforeAll(() => { - jest.spyOn(utils, "locationReload").mockImplementation(noop); - jest.spyOn(utils, "setLocationSearch"); - mockConfigureL10nFn.mockResolvedValue(true); + mockUseSystemFn.mockReturnValue({ l10n: { locale: "es_ES.UTF-8" } }); jest.spyOn(window.navigator, "languages", "get").mockReturnValue(["es-ES", "cs-CZ"]); }); - // remove the language cookie after each test - afterEach(() => { - // setting a cookie with already expired date removes it - document.cookie = "agamaLang=; path=/; expires=" + new Date(0).toUTCString(); - }); - - describe("when no URL query parameter is set", () => { - beforeEach(() => { - window.location.search = ""; - }); - - describe("when the language is already set", () => { - beforeEach(() => { - document.cookie = "agamaLang=en-US; path=/;"; - mockUseSystemFn.mockReturnValue({ l10n: { locale: "en_US.UTF-8" } }); - }); - - it("displays the children content and does not reload", async () => { - render( - - - - - , - ); - - // children are displayed - await screen.findByText("hello"); - - expect(utils.locationReload).not.toHaveBeenCalled(); - }); - }); - - describe("when the language is not set", () => { - beforeEach(() => { - // Ensure both, UI and backend mock languages, are in sync since - // client.setUILocale is mocked too. - // See navigator.language in the beforeAll at the top of the file. - mockUseSystemFn.mockReturnValue({ l10n: { locale: "es_ES.UTF-8" } }); - }); - - it("sets the language from backend", async () => { - render( - - - - - , - ); - - await waitFor(() => expect(utils.locationReload).toHaveBeenCalled()); - - // renders again after reloading - render( - - - - - , - ); - await waitFor(() => screen.getByText("hola")); - }); - }); - }); - - describe("when the URL query parameter is set to '?lang=cs-CZ'", () => { - beforeEach(() => { - history.replaceState(history.state, null, `http://localhost/?lang=cs-CZ`); - }); - - describe("when the language is already set to 'cs-CZ'", () => { - beforeEach(() => { - document.cookie = "agamaLang=cs-CZ; path=/;"; - mockUseSystemFn.mockReturnValue({ l10n: { locale: "cs_CZ.UTF-8" } }); - }); - - it("displays the children content and does not reload", async () => { - render( - - - - - , - ); - - // children are displayed - await screen.findByText("ahoj"); - expect(mockConfigureL10nFn).not.toHaveBeenCalled(); - - expect(document.cookie).toMatch(/agamaLang=cs-CZ/); - expect(utils.locationReload).not.toHaveBeenCalled(); - expect(utils.setLocationSearch).not.toHaveBeenCalled(); - }); - }); - - describe("when the language is set to 'en-US'", () => { - beforeEach(() => { - document.cookie = "agamaLang=en-US; path=/;"; - mockUseSystemFn.mockReturnValue({ l10n: { locale: "en_US" } }); - }); - - it("sets the 'cs-CZ' language and reloads", async () => { - render( - - - - - , - ); - - // renders again after reloading - render( - - - - - , - ); - - await waitFor(() => screen.getByText("ahoj")); - expect(mockConfigureL10nFn).toHaveBeenCalledWith({ locale: "cs_CZ.UTF-8" }); - }); - }); - - describe("when the language is not set", () => { - beforeEach(() => { - mockUseSystemFn.mockReturnValue({ l10n: {} }); - }); - - it("sets the 'cs-CZ' language and reloads", async () => { - render( - - - - - , - ); - - // reload the component - render( - - - - - , - ); + it("sets the language from the backend", async () => { + render( + + + + + , + ); - await waitFor(() => screen.getByText("ahoj")); - expect(mockConfigureL10nFn).toHaveBeenCalledWith({ locale: "cs_CZ.UTF-8" }); - }); - }); + await waitFor(() => screen.getByText("hola")); }); }); diff --git a/web/src/context/installerL10n.tsx b/web/src/context/installerL10n.tsx index dfadf4a63c..c9b9d4a17f 100644 --- a/web/src/context/installerL10n.tsx +++ b/web/src/context/installerL10n.tsx @@ -21,7 +21,6 @@ */ import React, { useCallback, useEffect, useState } from "react"; -import { locationReload, setLocationSearch } from "~/utils"; import agama from "~/agama"; import supportedLanguages from "~/languages.json"; import { useSystem } from "~/hooks/model/system"; @@ -33,8 +32,12 @@ const L10nContext = React.createContext(null); * Installer localization context. */ interface L10nContext { - language: string | undefined; - keymap: string | undefined; + // Current language in RFC 5646 format (e.g., "en-US"). + language: string; + // Current keymap (e.g., "en") + keymap: string; + // Loaded language matching in the po..js file (e.g., "en", "pt_BR"). + loadedLanguage: string; changeLanguage: (language: string) => Promise; changeKeymap: (keymap: string) => Promise; } @@ -49,56 +52,6 @@ function useInstallerL10n(): L10nContext { return context; } -/** - * Current language (in xx_XX format). - * - * It takes the language from the agamaLang cookie. - * - * @return Undefined if language is not set. - */ -function agamaLanguage(): string | undefined { - // language from cookie, empty string if not set (regexp taken from Cockpit) - // https://github.com/cockpit-project/cockpit/blob/98a2e093c42ea8cd2431cf15c7ca0e44bb4ce3f1/pkg/shell/shell-modals.jsx#L91 - return decodeURIComponent( - document.cookie.replace(/(?:(?:^|.*;\s*)agamaLang\s*=\s*([^;]*).*$)|^.*$/, "$1"), - ); -} - -/** - * Helper function for storing the Agama language. - * - * Automatically converts the language from xx_XX to xx-xx, as it is the one used by Agama. - * - * @param language - The new locale (e.g., "cs", "cs_CZ"). - * @return True if the locale was changed. - */ -function storeAgamaLanguage(language: string): boolean { - const current = agamaLanguage(); - if (current === language) return false; - - // Code taken from Cockpit. - const cookie = - "agamaLang=" + encodeURIComponent(language) + "; path=/; expires=Sun, 16 Jul 3567 06:23:41 GMT"; - document.cookie = cookie; - - return true; -} - -/** - * Returns the language tag from the query string. - * - * Query supports 'xx-xx', 'xx_xx', 'xx-XX' and 'xx_XX' formats. - * - * @return Undefined if not set. - */ -function languageFromQuery(): string | undefined { - const lang = new URLSearchParams(window.location.search).get("lang"); - if (!lang) return undefined; - - const [language, country] = lang.split(/[-_]/); - return country ? `${language.toLowerCase()}-${country.toUpperCase()}` : language; -} - /** * Generates a RFC 5646 (or BCP 78) language tag from a locale. * @@ -156,25 +109,6 @@ function findSupportedLanguage(languages: Array): string | undefined { } } -/** - * Reloads the page. - * - * It uses the window.location.replace instead of the reload function synchronizing the "lang" - * argument from the URL if present. - * - * @param newLanguage - new language to use. - */ -function reload(newLanguage: string) { - const query = new URLSearchParams(window.location.search); - if (query.has("lang") && query.get("lang") !== newLanguage) { - query.set("lang", newLanguage); - // Setting location search with a different value makes the browser to navigate to the new URL. - setLocationSearch(query.toString()); - } else { - locationReload(); - } -} - /** * Load the web frontend translations from the server. * @@ -229,76 +163,39 @@ function InstallerL10nProvider({ children?: React.ReactNode; }) { const { l10n } = useSystem(); - const [language, setLanguage] = useState(initialLanguage); - const [keymap, setKeymap] = useState(undefined); + const [loadedLanguage, setLoadedLanguage] = useState(initialLanguage); const locale = l10n?.locale; - const backendLanguage = locale ? languageFromLocale(locale) : null; - - const syncBackendLanguage = useCallback(async () => { - if (backendLanguage === language) return; - - // FIXME: fallback to en-US if the language is not supported. - await configureL10nAction({ locale: languageToLocale(language) }); - }, [language, backendLanguage]); + const language = locale ? languageFromLocale(locale) : initialLanguage; + const keymap = l10n?.keymap; const changeLanguage = useCallback( - async (lang?: string) => { - const wanted = lang || languageFromQuery(); - - // Just for development purposes - if (wanted === "xx" || wanted === "xx-XX") { - agama.language = wanted; - setLanguage(wanted); - return; - } - + async (lang: string) => { const candidateLanguages = [ - wanted, - wanted?.split("-")[0], // fallback to the language (e.g., "es" for "es-AR") - agamaLanguage(), - backendLanguage, + lang, + lang?.split("-")[0], // fallback to the language (e.g., "es" for "es-AR") + language, ].filter((l) => l); const newLanguage = findSupportedLanguage(candidateLanguages) || "en-US"; - const mustReload = storeAgamaLanguage(newLanguage); - document.documentElement.lang = newLanguage.split("-")[0]; - - if (mustReload) { - reload(newLanguage); - } else { - setLanguage(newLanguage); - - await loadTranslations(newLanguage); - } + await configureL10nAction({ locale: languageToLocale(newLanguage) }); }, - [backendLanguage, setLanguage], + [language], ); - const changeKeymap = useCallback( - async (id: string) => { - setKeymap(id); - await configureL10nAction({ keymap: id }); - }, - [setKeymap], - ); - - useEffect(() => { - if (!language) changeLanguage(); - }, [changeLanguage, language]); + const changeKeymap = useCallback(async (id: string) => { + await configureL10nAction({ keymap: id }); + }, []); useEffect(() => { if (!language) return; - syncBackendLanguage(); - }, [language, syncBackendLanguage]); - useEffect(() => { - setKeymap(l10n?.keymap); - }, [setKeymap, l10n]); + loadTranslations(language).then(() => setLoadedLanguage(agama.language.replace("-", "_"))); + }, [language, setLoadedLanguage]); - const value = { language, changeLanguage, keymap, changeKeymap }; + if (!loadedLanguage) return null; - if (!language) return null; + const value = { loadedLanguage, language, changeLanguage, keymap, changeKeymap }; return {children}; }