Skip to content

Commit

Permalink
feat(web): show license acceptance in product selection
Browse files Browse the repository at this point in the history
Still pending retrieving the license to display it in the dialog open
when user clicks on the license link.
  • Loading branch information
dgdavid committed Jan 17, 2025
1 parent 11e6800 commit e5eb11f
Show file tree
Hide file tree
Showing 5 changed files with 214 additions and 18 deletions.
91 changes: 91 additions & 0 deletions web/src/components/product/LicenseDialog.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<MenuToggle
ref={toggleRef}
onClick={() => setLocaleSelectorOpen(!localeSelectorOpen)}
isExpanded={localeSelectorOpen}
>
{locale}
</MenuToggle>
);

const onLocaleSelection = (_, locale: string) => {
setLocale(locale);
setLocaleSelectorOpen(false);
};

const eula = "Lorem ipsum";

return (
<Popup isOpen>
<Stack hasGutter>
<Split>
<SplitItem isFilled>
<h1>{sprintf(_("License for %s"), product.name)}</h1>
</SplitItem>
<Select
isOpen={localeSelectorOpen}
selected={locale}
onSelect={onLocaleSelection}
onOpenChange={(isOpen) => setLocaleSelectorOpen(!isOpen)}
toggle={localesToggler}
>
{locales.map((locale) => (
<SelectOption key={locale} value={locale}>
{locale}
</SelectOption>
))}
</Select>
</Split>
<Divider />
{eula}
</Stack>
<Popup.Actions>
<Popup.Confirm onClick={onClose}>{_("Close")}</Popup.Confirm>
</Popup.Actions>
</Popup>
);
}

export default LicenseDialog;
44 changes: 39 additions & 5 deletions web/src/components/product/ProductSelectionPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) [2022-2024] SUSE LLC
* Copyright (c) [2022-2025] SUSE LLC
*
* All Rights Reserved.
*
Expand Down Expand Up @@ -47,6 +47,7 @@ const microOs: Product = {
icon: "microos.svg",
description: "MicroOS description",
registration: "no",
licenseId: "fake.license",
};

let mockSelectedProduct: Product;
Expand All @@ -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(<ProductSelectionPage />);
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(<ProductSelectionPage />);

Check failure on line 101 in web/src/components/product/ProductSelectionPage.test.tsx

View workflow job for this annotation

GitHub Actions / frontend_build (18.x)

'user' is assigned a value but never used
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: "" };
Expand Down Expand Up @@ -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(<ProductSelectionPage />);
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(<ProductSelectionPage />);
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);
Expand Down
92 changes: 80 additions & 12 deletions web/src/components/product/ProductSelectionPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) [2022-2024] SUSE LLC
* Copyright (c) [2022-2025] SUSE LLC
*
* All Rights Reserved.
*
Expand Down Expand Up @@ -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";
Expand All @@ -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 }) => (
<GridItem sm={10} smOffset={1} lg={8} lgOffset={2} xl={6} xlOffset={3}>
Expand Down Expand Up @@ -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 <Navigate to={PATHS.root} />;
Expand All @@ -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 (
<Page>
Expand All @@ -131,7 +155,7 @@ function ProductSelectionPage() {
key={index}
product={product}
isChecked={nextProduct === product}
onChange={() => setNextProduct(product)}
onChange={() => selectProduct(product)}
/>
))}
</List>
Expand All @@ -140,16 +164,60 @@ function ProductSelectionPage() {
</Grid>
</Form>
</Center>
{showLicense && (
<LicenseDialog
onClose={() => setShowLicense(false)}
product={nextProduct || selectedProduct}
/>
)}
</Page.Content>
<Page.Actions>
{selectedProduct && !isLoading && <BackLink />}
<Page.Submit
form="productSelectionForm"
isDisabled={isSelectionDisabled}
isLoading={isLoading}
>
{_("Select")}
</Page.Submit>
<Page.Actions justifyContent="none">
<Grid>
<ResponsiveGridItem>
<Stack hasGutter>
<StackItem>
<Flex
justifyContent={{ default: "justifyContentFlexEnd" }}
columnGap={{ default: "columnGapSm" }}
>
{mountLicenseCheckbox && (
<Checkbox
isChecked={licenseAccepted}
onChange={(_, accepted) => setLicenseAccepted(accepted)}
isDisabled={selectedProduct === nextProduct}
id="license-acceptance"
form="productSelectionForm"
label={
<>
{eulaTextStart}{" "}
<Button variant="link" isInline onClick={() => setShowLicense(true)}>
{eulaTextLink}
</Button>{" "}
{eulaTextEnd}
</>
}
/>
)}
</Flex>
</StackItem>
<StackItem>
<Flex
justifyContent={{ default: "justifyContentFlexEnd" }}
columnGap={{ default: "columnGapSm" }}
>
{selectedProduct && !isLoading && <BackLink />}
<Page.Submit
form="productSelectionForm"
isDisabled={isSelectionDisabled}
isLoading={isLoading}
>
{_("Select")}
</Page.Submit>
</Flex>
</StackItem>
</Stack>
</ResponsiveGridItem>
</Grid>
</Page.Actions>
</Page>
);
Expand Down
3 changes: 2 additions & 1 deletion web/src/components/product/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) [2023-2024] SUSE LLC
* Copyright (c) [2023-2025] SUSE LLC
*
* All Rights Reserved.
*
Expand All @@ -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";
2 changes: 2 additions & 0 deletions web/src/types/software.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down

0 comments on commit e5eb11f

Please sign in to comment.