diff --git a/web/src/components/product/LicenseDialog.tsx b/web/src/components/product/LicenseDialog.tsx new file mode 100644 index 0000000000..c6d2933501 --- /dev/null +++ b/web/src/components/product/LicenseDialog.tsx @@ -0,0 +1,91 @@ +/* + * 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, { useState } from "react"; +import { Popup } from "~/components/core"; +import { _ } from "~/i18n"; +import { + Divider, + MenuToggle, + ModalProps, + Select, + SelectOption, + Split, + SplitItem, + Stack, +} from "@patternfly/react-core"; +import { Product } from "~/types/software"; +import { sprintf } from "sprintf-js"; + +function LicenseDialog({ onClose, product }: { onClose: ModalProps["onClose"]; product: Product }) { + const [locale, setLocale] = useState("en"); + const [localeSelectorOpen, setLocaleSelectorOpen] = useState(false); + const locales = ["en", "es", "de", "cz", "pt"]; + const localesToggler = (toggleRef) => ( + setLocaleSelectorOpen(!localeSelectorOpen)} + isExpanded={localeSelectorOpen} + > + {locale} + + ); + + const onLocaleSelection = (_, locale: string) => { + setLocale(locale); + setLocaleSelectorOpen(false); + }; + + const eula = "Lorem ipsum"; + + return ( + + + + +

{sprintf(_("License for %s"), product.name)}

+
+ +
+ + {eula} +
+ + {_("Close")} + +
+ ); +} + +export default LicenseDialog; diff --git a/web/src/components/product/ProductSelectionPage.test.tsx b/web/src/components/product/ProductSelectionPage.test.tsx index da2b4d9d67..7211fb3481 100644 --- a/web/src/components/product/ProductSelectionPage.test.tsx +++ b/web/src/components/product/ProductSelectionPage.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2024] SUSE LLC + * Copyright (c) [2022-2025] SUSE LLC * * All Rights Reserved. * @@ -47,6 +47,7 @@ const microOs: Product = { icon: "microos.svg", description: "MicroOS description", registration: "no", + licenseId: "fake.license", }; let mockSelectedProduct: Product; @@ -67,10 +68,43 @@ jest.mock("~/queries/software", () => ({ describe("ProductSelectionPage", () => { beforeEach(() => { - mockSelectedProduct = tumbleweed; + mockSelectedProduct = microOs; registrationInfoMock = { key: "", email: "" }; }); + describe("when user select a product with license", () => { + beforeEach(() => { + mockSelectedProduct = undefined; + }); + + it("force license acceptance for allowing product selection", async () => { + const { user } = installerRender(); + 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 }); + await user.click(microOsOption); + const licenseCheckbox = screen.getByRole("checkbox", { name: /I have read and accept/ }); + expect(licenseCheckbox).not.toBeChecked(); + expect(selectButton).toBeDisabled(); + await user.click(licenseCheckbox); + expect(licenseCheckbox).toBeChecked(); + expect(selectButton).not.toBeDisabled(); + }); + }); + + describe("when there is a product with license previouly selected", () => { + beforeEach(() => { + mockSelectedProduct = microOs; + }); + + it("does not allow revoking license acceptance", async () => { + const { user } = installerRender(); + const licenseCheckbox = screen.getByRole("checkbox", { name: /I have read and accept/ }); + expect(licenseCheckbox).toBeChecked(); + expect(licenseCheckbox).toBeDisabled(); + }); + }); + describe("when there is a registration code set", () => { beforeEach(() => { registrationInfoMock = { key: "INTERNAL-USE-ONLY-1234-5678", email: "" }; @@ -103,18 +137,18 @@ describe("ProductSelectionPage", () => { describe("when the user chooses a product and hits the confirmation button", () => { it("triggers the product selection", async () => { const { user } = installerRender(); - const productOption = screen.getByRole("radio", { name: microOs.name }); + const productOption = screen.getByRole("radio", { name: tumbleweed.name }); const selectButton = screen.getByRole("button", { name: "Select" }); await user.click(productOption); await user.click(selectButton); - expect(mockConfigMutation).toHaveBeenCalledWith({ product: microOs.id }); + expect(mockConfigMutation).toHaveBeenCalledWith({ product: tumbleweed.id }); }); }); describe("when the user chooses a product but hits the cancel button", () => { it("does not trigger the product selection and goes back", async () => { const { user } = installerRender(); - const productOption = screen.getByRole("radio", { name: microOs.name }); + const productOption = screen.getByRole("radio", { name: tumbleweed.name }); const cancelButton = screen.getByRole("button", { name: "Cancel" }); await user.click(productOption); await user.click(cancelButton); diff --git a/web/src/components/product/ProductSelectionPage.tsx b/web/src/components/product/ProductSelectionPage.tsx index 994f4ac269..6ccdaebfd9 100644 --- a/web/src/components/product/ProductSelectionPage.tsx +++ b/web/src/components/product/ProductSelectionPage.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2024] SUSE LLC + * Copyright (c) [2022-2025] SUSE LLC * * All Rights Reserved. * @@ -33,6 +33,9 @@ import { Stack, FormGroup, Button, + Checkbox, + StackItem, + Flex, } from "@patternfly/react-core"; import { Navigate, useNavigate } from "react-router-dom"; import { Page } from "~/components/core"; @@ -44,6 +47,8 @@ import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; import { PATHS } from "~/router"; import { isEmpty } from "~/utils"; +import { Product } from "~/types/software"; +import LicenseDialog from "./LicenseDialog"; const ResponsiveGridItem = ({ children }) => ( @@ -102,6 +107,10 @@ function ProductSelectionPage() { const registration = useRegistration(); const { products, selectedProduct } = useProduct({ suspense: true }); const [nextProduct, setNextProduct] = useState(selectedProduct); + // FIXME: should not be accepted by default first selectedProduct is accepted + // because it's a singleProduct iso. + const [licenseAccepted, setLicenseAccepted] = useState(!!selectedProduct); + const [showLicense, setShowLicense] = useState(false); const [isLoading, setIsLoading] = useState(false); if (!isEmpty(registration?.key)) return ; @@ -115,7 +124,22 @@ function ProductSelectionPage() { } }; - const isSelectionDisabled = !nextProduct || nextProduct === selectedProduct; + const selectProduct = (product: Product) => { + setNextProduct(product); + setLicenseAccepted(selectedProduct === product); + }; + + const selectionHasChanged = nextProduct && nextProduct !== selectedProduct; + const mountLicenseCheckbox = !isEmpty(nextProduct?.licenseId); + const isSelectionDisabled = !selectionHasChanged || (mountLicenseCheckbox && !licenseAccepted); + + const [eulaTextStart, eulaTextLink, eulaTextEnd] = sprintf( + // TRANSLATORS: Text used for the license acceptance checkbox. %s will be + // replaced with the product name and the text in the square brackets [] is + // used for the link to show the license, please keep the brackets. + _("I have read and accept the [license] for %s"), + nextProduct?.name || selectedProduct?.name, + ).split(/[[\]]/); return ( @@ -131,7 +155,7 @@ function ProductSelectionPage() { key={index} product={product} isChecked={nextProduct === product} - onChange={() => setNextProduct(product)} + onChange={() => selectProduct(product)} /> ))} @@ -140,16 +164,60 @@ function ProductSelectionPage() { + {showLicense && ( + setShowLicense(false)} + product={nextProduct || selectedProduct} + /> + )} - - {selectedProduct && !isLoading && } - - {_("Select")} - + + + + + + + {mountLicenseCheckbox && ( + setLicenseAccepted(accepted)} + isDisabled={selectedProduct === nextProduct} + id="license-acceptance" + form="productSelectionForm" + label={ + <> + {eulaTextStart}{" "} + {" "} + {eulaTextEnd} + + } + /> + )} + + + + + {selectedProduct && !isLoading && } + + {_("Select")} + + + + + + ); diff --git a/web/src/components/product/index.ts b/web/src/components/product/index.ts index fc63c951d0..2cdef9b4cb 100644 --- a/web/src/components/product/index.ts +++ b/web/src/components/product/index.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023-2024] SUSE LLC + * Copyright (c) [2023-2025] SUSE LLC * * All Rights Reserved. * @@ -24,3 +24,4 @@ export { default as ProductSelectionPage } from "./ProductSelectionPage"; export { default as ProductSelectionProgress } from "./ProductSelectionProgress"; export { default as ProductRegistrationPage } from "./ProductRegistrationPage"; export { default as ProductRegistrationAlert } from "./ProductRegistrationAlert"; +export { default as EulaDialog } from "./LicenseDialog"; diff --git a/web/src/types/software.ts b/web/src/types/software.ts index f35a9a609e..adb749021d 100644 --- a/web/src/types/software.ts +++ b/web/src/types/software.ts @@ -43,6 +43,8 @@ type Product = { icon?: string; /** If product is registrable or not */ registration: "no" | "optional" | "mandatory"; + /** The product license id, if any */ + licenseId?: string; }; type PatternsSelection = { [key: string]: SelectedBy };