diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index c92fd1c0b4..932d234607 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,13 @@ +------------------------------------------------------------------- +Mon Jan 19 09:00:12 UTC 2026 - David Diaz + +- Enhance product selection page (gh#agama-project/agama#3039): + - Keep the selected product’s radio button checked after + interacting with other controls. + - Improved UX with persistent product details on large screens, + clearer submission-disabled messages, and other minor yet + useful UI tweaks. + ------------------------------------------------------------------- Fri Jan 16 12:58:15 UTC 2026 - David Diaz diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index c213df3f20..1d84600696 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -256,13 +256,16 @@ strong { } } +.sticky-top { + position: sticky; + top: 1em; +} + #productSelectionForm { input[type="radio"] { - vertical-align: middle; - margin-inline-end: 1ch; - flex-shrink: 0; - inline-size: 20px; - block-size: 20px; + align-self: center; + inline-size: 1.2em; + block-size: 1.2em; } .pf-v6-c-card { @@ -273,17 +276,10 @@ strong { label { cursor: pointer; } + } - label::after { - content: ""; - position: absolute; - width: 100%; - height: 100%; - top: 0; - right: 0; - bottom: 0; - left: 0; - } + .pf-v6-c-radio { + row-gap: 0; } } diff --git a/web/src/components/product/ProductSelectionPage.test.tsx b/web/src/components/product/ProductSelectionPage.test.tsx index ecd2ac6b12..461a57f5c7 100644 --- a/web/src/components/product/ProductSelectionPage.test.tsx +++ b/web/src/components/product/ProductSelectionPage.test.tsx @@ -20,12 +20,14 @@ * find current contact information at www.suse.com. */ -import React from "react"; -import { screen } from "@testing-library/react"; +import React, { act } from "react"; +import { screen, within } from "@testing-library/react"; import { installerRender, mockNavigateFn, mockProduct } from "~/test-utils"; import { useSystem } from "~/hooks/model/system"; +import { useSystem as useSystemSoftware } from "~/hooks/model/system/software"; import { Product } from "~/types/software"; import ProductSelectionPage from "./ProductSelectionPage"; +import { ROOT } from "~/routes/paths"; const tumbleweed: Product = { id: "Tumbleweed", @@ -45,12 +47,16 @@ const microOs: Product = { }; const mockPatchConfigFn = jest.fn(); +const mockUseSystemFn: jest.Mock> = jest.fn(); +const mockUseSystemSoftwareFn: jest.Mock> = jest.fn(); // FIXME: add ad use a mockSystem from test-utils instead jest.mock("~/components/core/InstallerOptions", () => () => (
ProductRegistrationAlert Mock
)); +jest.mock("~/components/product/LicenseDialog", () => () =>
LicenseDialog Mock
); + jest.mock("~/api", () => ({ ...jest.requireActual("~/api"), patchConfig: (payload) => mockPatchConfigFn(payload), @@ -58,112 +64,270 @@ jest.mock("~/api", () => ({ jest.mock("~/hooks/model/system", () => ({ ...jest.requireActual("~/hooks/model/system"), - useSystem: (): ReturnType => ({ - products: [tumbleweed, microOs], - }), + useSystem: () => mockUseSystemFn(), +})); + +jest.mock("~/hooks/model/system/software", () => ({ + ...jest.requireActual("~/hooks/model/system/software"), + useSystem: () => mockUseSystemSoftwareFn(), })); describe("ProductSelectionPage", () => { - describe("when user select a product with license", () => { - beforeEach(() => { - mockProduct(undefined); + beforeEach(() => { + mockUseSystemFn.mockReturnValue({ + products: [tumbleweed, microOs], }); - 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(); + mockUseSystemSoftwareFn.mockReturnValue({ + addons: [], + patterns: [], + repositories: [], }); }); - describe("when there is a product with license previouly selected", () => { - beforeEach(() => { - mockProduct(microOs); + // Regression test: + // On component re-renders (e.g. after clicking a header option), the selected + // product radio became unchecked because selection logic compared object + // references instead of stable identifiers. Even though the products had + // identical data, new object instances caused the comparison to fail. This + // 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 microOsOption = screen.getByRole("radio", { name: microOs.name }); + expect(microOsOption).not.toBeChecked(); + await user.click(microOsOption); + expect(microOsOption).toBeChecked(); + act(() => { + mockUseSystemFn.mockReturnValue({ + // Same products, new objects + products: [{ ...tumbleweed }, { ...microOs }], + }); }); + rerender(); + expect(microOsOption).toBeChecked(); + // Product must still checked. + expect(microOsOption).toBeChecked(); + }); - it("does not allow revoking license acceptance", () => { - installerRender(); - const licenseCheckbox = screen.getByRole("checkbox", { name: /I have read and accept/ }); - expect(licenseCheckbox).toBeChecked(); - expect(licenseCheckbox).toBeDisabled(); + it("force license acceptance for products with license", async () => { + mockProduct(undefined); + 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(); + }); + + it("resets license acceptance when switching between products with licenses", async () => { + const productWithLicense1 = { ...microOs, id: "Product1", name: "Product 1" }; + const productWithLicense2 = { ...microOs, id: "Product2", name: "Product 2" }; + + mockProduct(undefined); + mockUseSystemFn.mockReturnValue({ + products: [productWithLicense1, productWithLicense2], }); + + const { user } = installerRender(); + + // Select first product and accept license + const product1Option = screen.getByRole("radio", { name: "Product 1" }); + await user.click(product1Option); + const licenseCheckbox = screen.getByRole("checkbox", { name: /I have read and accept/ }); + await user.click(licenseCheckbox); + expect(licenseCheckbox).toBeChecked(); + + // Switch to second product + const product2Option = screen.getByRole("radio", { name: "Product 2" }); + await user.click(product2Option); + + // License checkbox should be unchecked + expect(licenseCheckbox).not.toBeChecked(); }); - // FIXME: re-enable it when registration is ready in v2 - describe.skip("when product is registered", () => { - beforeEach(() => { - // registrationInfoMock = { - // registered: true, - // key: "INTERNAL-USE-ONLY-1234-5678", - // email: "", - // url: "", - // }; + it("navigates to root path when product is registered (registration exists)", async () => { + mockUseSystemSoftwareFn.mockReturnValue({ + addons: [], + patterns: [], + repositories: [], + registration: { code: "INTERNAL-USE-ONLY-1234-5678", addons: [] }, }); + installerRender(); + await screen.findByText("Navigating to /"); + }); - it("navigates to root path", async () => { - installerRender(); - await screen.findByText("Navigating to /"); + it("renders the Cancel button when a product is already seelected ", () => { + mockProduct(microOs); + installerRender(); + screen.getByRole("link", { name: "Cancel" }); + }); + + it("does not render the Cancel button if product no selected yet", () => { + mockProduct(undefined); + installerRender(); + 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 productOption = screen.getByRole("radio", { name: tumbleweed.name }); + const selectButton = screen.getByRole("button", { name: "Select" }); + await user.click(productOption); + await user.click(selectButton); + expect(mockPatchConfigFn).toHaveBeenCalledWith({ product: { id: tumbleweed.id } }); + }); + + it("does not trigger the product selection if user selects a product but clicks o cancel button", async () => { + mockProduct(microOs); + const { user } = installerRender(); + const productOption = screen.getByRole("radio", { name: tumbleweed.name }); + const cancel = screen.getByRole("link", { name: "Cancel" }); + expect(cancel).toHaveAttribute("href", ROOT.overview); + await user.click(productOption); + await user.click(cancel); + expect(mockPatchConfigFn).not.toHaveBeenCalled(); + }); + + it.todo("make navigation test work"); + it.skip("navigates to root after successful product selection", async () => { + mockProduct(undefined); + const { user } = installerRender(); + + const tumbleweedOption = screen.getByRole("radio", { name: tumbleweed.name }); + await user.click(tumbleweedOption); + + const selectButton = screen.getByRole("button", { name: /Select/ }); + await user.click(selectButton); + + // Mock the product as selected + act(() => { + mockProduct(tumbleweed); }); + + expect(mockNavigateFn).toHaveBeenCalledWith(ROOT.root); }); - describe("when there is a product already selected", () => { - beforeEach(() => { + describe("ProductFormSubmitLabel", () => { + it("renders 'Change' or 'Change to %product.name' when changing from one product to another", async () => { mockProduct(microOs); - }); + const { user } = installerRender(); - it("renders the Cancel button", () => { - installerRender(); - screen.getByRole("button", { name: "Cancel" }); + screen.getByRole("button", { name: "Change" }); + const tumbleweedOption = screen.getByRole("radio", { name: tumbleweed.name }); + await user.click(tumbleweedOption); + screen.getByRole("button", { name: "Change to openSUSE Tumbleweed" }); }); - }); - describe("when there is not a product selected yet", () => { - beforeEach(() => { + it("renders 'Select' or 'Select %product.name' during initial selection", async () => { mockProduct(undefined); + const { user } = installerRender(); + + screen.getByRole("button", { name: "Select" }); + const tumbleweedOption = screen.getByRole("radio", { name: tumbleweed.name }); + await user.click(tumbleweedOption); + screen.getByRole("button", { name: "Select openSUSE Tumbleweed" }); }); + }); - it("does not render the Cancel button", () => { + describe("ProductFormSubmitLabelHelp", () => { + it("renders warning when no product is selected", () => { + mockProduct(undefined); installerRender(); - expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); + + screen.getByText("Select a product to continue."); }); - }); - describe("when the user chooses a product and hits the confirmation button", () => { - beforeEach(() => { + it("renders warning when license is not accepted", async () => { mockProduct(undefined); + const { user } = installerRender(); + + const microOsOption = screen.getByRole("radio", { name: microOs.name }); + await user.click(microOsOption); + screen.getByText("License acceptance is required to continue."); }); - it("triggers the product selection", async () => { + it("hides helper text when product is selected and license is accepted", async () => { + mockProduct(undefined); const { user } = installerRender(); - const productOption = screen.getByRole("radio", { name: tumbleweed.name }); - const selectButton = screen.getByRole("button", { name: "Select" }); - await user.click(productOption); - await user.click(selectButton); - expect(mockPatchConfigFn).toHaveBeenCalledWith({ product: { id: tumbleweed.id } }); + + const microOsOption = screen.getByRole("radio", { name: microOs.name }); + await user.click(microOsOption); + const licenseCheckbox = screen.getByRole("checkbox", { name: /I have read and accept/ }); + await user.click(licenseCheckbox); + expect( + screen.queryByText("License acceptance is required to continue."), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Select a product to continue.")).not.toBeInTheDocument(); }); }); - describe("when the user chooses a product but hits the cancel button", () => { - beforeEach(() => { + describe("CurrentProductInfo", () => { + it("renders current product information when changing products", () => { mockProduct(microOs); + installerRender(); + + const sectionHeading = screen.getByRole("heading", { level: 2, name: "Current selection" }); + const section = sectionHeading.closest("section"); + within(section).getByRole("heading", { level: 3, name: microOs.name }); + within(section).getByText(microOs.description); }); - it("does not trigger the product selection and goes back", async () => { + it("renders view license button for products with license", () => { + mockProduct(microOs); + installerRender(); + + const sectionHeading = screen.getByRole("heading", { level: 2, name: "Current selection" }); + const section = sectionHeading.closest("section"); + within(section).getByRole("button", { name: "View license" }); + }); + + it("does not render view license button for products without license", () => { + mockProduct(tumbleweed); + installerRender(); + + const sectionHeading = screen.getByRole("heading", { level: 2, name: "Current selection" }); + const section = sectionHeading.closest("section"); + expect( + within(section).queryByRole("button", { name: "View license" }), + ).not.toBeInTheDocument(); + }); + + it("does not render when no product is selected", () => { + mockProduct(undefined); + installerRender(); + + expect(screen.queryByText("Current selection")).not.toBeInTheDocument(); + }); + }); + + describe("LicenseButton", () => { + it("opens license dialog", async () => { + mockProduct(microOs); const { user } = installerRender(); - const productOption = screen.getByRole("radio", { name: tumbleweed.name }); - const cancelButton = screen.getByRole("button", { name: "Cancel" }); - await user.click(productOption); - await user.click(cancelButton); - expect(mockPatchConfigFn).not.toHaveBeenCalled(); - expect(mockNavigateFn).toHaveBeenCalledWith("/"); + + const viewLicenseButton = screen.getByRole("button", { name: "View license" }); + await user.click(viewLicenseButton); + screen.getByText("LicenseDialog Mock"); + }); + }); + + describe("ProductFormProductOption", () => { + it("displays license requirement label for products with licenses", () => { + mockProduct(undefined); + mockUseSystemFn.mockReturnValue({ products: [microOs] }); + const { rerender } = installerRender(); + screen.getByText("License acceptance required"); + + mockUseSystemFn.mockReturnValue({ products: [tumbleweed] }); + rerender(); + expect(screen.queryByText("License acceptance required")).toBeNull(); }); }); }); diff --git a/web/src/components/product/ProductSelectionPage.tsx b/web/src/components/product/ProductSelectionPage.tsx index b9586000e1..562db7b037 100644 --- a/web/src/components/product/ProductSelectionPage.tsx +++ b/web/src/components/product/ProductSelectionPage.tsx @@ -21,10 +21,14 @@ */ import React, { useEffect, useState } from "react"; +import { isEmpty } from "radashi"; +import { sprintf } from "sprintf-js"; import { Button, + ButtonProps, Card, CardBody, + CardTitle, Checkbox, Content, Divider, @@ -35,30 +39,53 @@ import { FormGroup, Grid, GridItem, + HelperText, + HelperTextItem, + Label, List, ListItem, + Radio, Split, Stack, StackItem, + Title, } from "@patternfly/react-core"; -import { useNavigate } from "react-router"; -import { NestedContent, Page, SubtleContent } from "~/components/core"; -import pfTextStyles from "@patternfly/react-styles/css/utilities/Text/text"; -import pfRadioStyles from "@patternfly/react-styles/css/components/Radio/radio"; -import { isEmpty } from "radashi"; -import { sprintf } from "sprintf-js"; -import { n_, _ } from "~/i18n"; +import { Navigate, useNavigate } from "react-router"; +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 LicenseDialog from "./LicenseDialog"; +import { patchConfig } from "~/api"; import { useProductInfo } from "~/hooks/model/config/product"; import { useSystem } from "~/hooks/model/system"; -import { patchConfig } from "~/api"; +import { useSystem as useSystemSoftware } from "~/hooks/model/system/software"; import { ROOT } from "~/routes/paths"; import { Product } from "~/model/system"; -import ProductLogo from "~/components/product/ProductLogo"; -import Text from "../core/Text"; +import { n_, _ } from "~/i18n"; -const Option = ({ product, isChecked, onChange, isSelectable = true, isTruncating = true }) => { +import pfTextStyles from "@patternfly/react-styles/css/utilities/Text/text"; + +/** + * Props for ProductFormProductOption component + */ +type ProductFormProductOptionProps = { + /** The product to display as an option */ + product: Product; + /** Whether this product option is currently selected */ + isChecked: boolean; + /** Callback fired when the product is selected */ + onChange: () => void; +}; + +/** + * Renders a single product option as a radio button with expandable details. + */ +const ProductFormProductOption = ({ + product, + isChecked, + onChange, +}: ProductFormProductOptionProps) => { const detailsId = `${product.id}-details`; const currentLocale = agama.language.replace("-", "_"); @@ -71,27 +98,29 @@ const Option = ({ product, isChecked, onChange, isSelectable = true, isTruncatin - - -

- {isTruncating ? ( - + + {product.name} + + } + body={ + + {product.license && ( + + {product.license && ( + + )} + + )} + {translatedDescription} - - ) : ( - translatedDescription - )} -

+ + } + />
@@ -113,172 +140,379 @@ const Option = ({ product, isChecked, onChange, isSelectable = true, isTruncatin ); }; -const BackLink = () => { - const navigate = useNavigate(); +/** + * Props for LicenseButton component + */ +type LicenseButtonProps = Omit & { + /** The product whose license will be displayed */ + product: Product; +}; + +/** + * Button that opens a license dialog when clicked. + */ +const LicenseButton = ({ product, children, ...props }: LicenseButtonProps) => { + const [showEula, setShowEula] = useState(false); + + const open = () => setShowEula(true); + const close = () => setShowEula(false); + + return ( + <> + + {showEula && } + + ); +}; + +/** + * Props for EulaCheckbox component + */ +type EulaCheckboxProps = { + /** The product whose license is being accepted */ + product: Product; + /** Callback fired when checkbox state changes */ + onChange: (accepted: boolean) => void; + /** Whether the checkbox is currently checked (i.e., license accepted) */ + isChecked: boolean; +}; + +/** + * Checkbox for accepting a product's license agreement. + * Includes a link to view the full license text. + */ +const EulaCheckbox = ({ product, onChange, isChecked }: EulaCheckboxProps) => { + 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"), + product?.name, + ).split(/[[\]]/); + + return ( + <> + onChange(accepted)} + id="license-acceptance" + label={ + <> + {eulaTextStart}{" "} + + {eulaTextLink} + {" "} + {eulaTextEnd} + + } + /> + + ); +}; +/** + * Props for ProductFormSubmitLabel component + */ +type ProductFormSubmitLabelProps = { + /** The product currently configured in the system */ + currentProduct?: Product; + /** The product selected by the user in the UI (not yet confirmed) */ + selectedProduct?: Product; +}; + +/** + * Renders the submit button label based on context. + * Shows "Change to [Product]" or "Select [Product]" depending on whether + * user is selecting a product for first time or making a change. + */ +const ProductFormSubmitLabel = ({ + currentProduct, + selectedProduct, +}: ProductFormSubmitLabelProps) => { + const action = currentProduct ? _("Change to %s") : _("Select %s"); + const fallback = currentProduct ? _("Change") : _("Select"); + + if (!selectedProduct) { + return fallback; + } + + const [labelStart, labelEnd] = action.split("%s"); + + return ( + + {labelStart} {selectedProduct.name} {labelEnd} + + ); +}; + +/** + * Props for ProductFormSubmitLabelHelp component + */ +type ProductFormSubmitLabelHelpProps = { + /** The product selected by the user */ + selectedProduct?: Product; + /** Whether the selected product requires license acceptance */ + hasEula: boolean; + /** Whether the user has accepted the license */ + isEulaAccepted: boolean; +}; + +/** + * Displays helper text below the submit button explaining why it's disabled. + * Shows warnings for missing product selection or not accepted license. + */ +const ProductFormSubmitLabelHelp = ({ + selectedProduct, + hasEula, + isEulaAccepted, +}: ProductFormSubmitLabelHelpProps) => { + let text: string; + + if (!selectedProduct) { + text = _("Select a product to continue."); + } else if (hasEula && !isEulaAccepted) { + text = _("License acceptance is required to continue."); + } else { + return; + } + + return ( + + {text} + + ); +}; + +/** + * Props for ProductForm component + */ +type ProductFormProps = { + /** List of all available products */ + products: Product[]; + /** The product currently configured in the system */ + currentProduct?: Product; + /** Callback fired when the form is submitted with a selected product */ + onSubmit: (product: Product) => void; + /** Whether the form is in a waiting/submitting state */ + isWaiting: boolean; +}; + +/** + * Form for selecting a product. + * + * Manages product selection state, license acceptance, and form validation. + * Excludes the current product from the list of options. + */ +const ProductForm = ({ products, currentProduct, onSubmit, isWaiting }: ProductFormProps) => { + const [selectedProduct, setSelectedProduct] = useState(); + const [eulaAccepted, setEulaAccepted] = useState(false); + const mountEulaCheckbox = selectedProduct && !isEmpty(selectedProduct.license); + const isSelectionDisabled = !selectedProduct || isWaiting || (mountEulaCheckbox && !eulaAccepted); + + const onProductSelectionChange = (product) => { + setEulaAccepted(false); + setSelectedProduct(product); + }; + + const onFormSubmission = (e: React.FormEvent) => { + e.preventDefault(); + + onSubmit(selectedProduct); + }; + + return ( +
+ + + {products.map((product, index) => { + if (product.id === currentProduct?.id) return undefined; + + return ( + onProductSelectionChange(product)} + /> + ); + })} + + + + {mountEulaCheckbox && ( + + + + )} + + + + + + {currentProduct && ( + + {_("Cancel")} + + )} + + + + + + +
+ ); +}; + +/** + * Props for CurrentProductInfo component + */ +type CurrentProductInfoProps = { + /** The currently configured product to display */ + product?: Product; +}; + +/** + * Card displaying information about the currently selected product. + * + * Shows product name, description, and a link to view the license if applicable. + */ +const CurrentProductInfo = ({ product }: CurrentProductInfoProps) => { + if (!product) return; + return ( - + + {_("Current selection")} + + + + <ProductLogo product={product} width="2em" /> {product.name} + + + {product.description} + + {product.license && ( + + {_("View license")} + + )} + + + ); }; -function ProductSelectionPage() { +/** + * Content component for the product selection page. + * + * Handles the product selection workflow including: + * - Displaying available products. + * - Managing selection and submission state. + * - Navigating after successful product configuration. + * - Showing current product information. + */ +const ProductSelectionContent = () => { const navigate = useNavigate(); const { products } = useSystem(); - const selectedProduct = useProductInfo(); - 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 [submittedSelection, setSubmmitedSelection] = useState(); + const currentProduct = useProductInfo(); const [isWaiting, setIsWaiting] = useState(false); - // if (registration?.registered && selectedProduct) return ; - useEffect(() => { if (!isWaiting) return; - if (selectedProduct?.id === nextProduct?.id) { + if (currentProduct?.id === submittedSelection?.id) { navigate(ROOT.root); } - }, [isWaiting, navigate, nextProduct, selectedProduct]); - - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + }, [isWaiting, navigate, currentProduct, submittedSelection]); - if (nextProduct) { - patchConfig({ product: { id: nextProduct.id } }); - setIsWaiting(true); - } - }; - - const selectProduct = (product: Product) => { - setNextProduct(product); - setLicenseAccepted(selectedProduct === product); + const onSubmit = async (selectedProduct: Product) => { + setIsWaiting(true); + setSubmmitedSelection(selectedProduct); + patchConfig({ product: { id: selectedProduct.id } }); }; - const selectionHasChanged = nextProduct && nextProduct !== selectedProduct; - const mountLicenseCheckbox = !isEmpty(nextProduct?.license); - const isSelectionDisabled = - isWaiting || !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(/[[\]]/); - - const [selectedTitleStart, selectedTitleEnd] = _("Currently selected %s").split("%s"); + const introText = n_( + "Select a product and confirm your choice.", + "Select a product and confirm your choice at the end of the list.", + products.length - 1, + ); return ( + + {introText} + {currentProduct && ( + + {_( + "Installation settings will automatically update to match the new product's defaults.", + )} + + )} + + - - {selectedProduct && ( - <> - - - {selectedTitleStart} {selectedProduct.name}{" "} - {selectedTitleEnd} - - - - - {selectedProduct.description} - - - - - - - )} + + - - - {selectedProduct - ? sprintf( - n_( - "There is other product available. Selecting a different product will automatically adjust some installation settings to match the chosen product's defaults.", - "There are 2 other products available. Selecting a different product will automatically adjust some installation settings to match the chosen product's defaults.", - products.length - 1, - ), - products.length - 1, - ) - : sprintf(_("There are %d products available"), products.length)} - -
- - - {products.map((product, index) => { - if (product === selectedProduct) return undefined; - - return ( - - -
+ +
- {showLicense && ( - setShowLicense(false)} - product={nextProduct || selectedProduct} - /> - )} - - - {mountLicenseCheckbox && ( - setLicenseAccepted(accepted)} - isDisabled={selectedProduct === nextProduct} - id="license-acceptance" - form="productSelectionForm" - label={ - <> - {eulaTextStart}{" "} - {" "} - {eulaTextEnd} - - } - /> - )} - - - - - {selectedProduct ? _("Change") : _("Select")} - - {selectedProduct && } - - -
); -} +}; + +/** + * Main page component for product selection. + * + * Redirects to root if the system is already registered. + * Otherwise, renders the product selection interface allowing users to: + * - Choose from available products + * - View current product information (when changing products) + */ +export default function ProductSelectionPage() { + const { registration } = useSystemSoftware(); -export default ProductSelectionPage; + if (registration) return ; + + return ; +}