diff --git a/web/src/components/core/Summary.tsx b/web/src/components/core/Summary.tsx index b093180ccf..ab1f1765a9 100644 --- a/web/src/components/core/Summary.tsx +++ b/web/src/components/core/Summary.tsx @@ -23,20 +23,46 @@ import React from "react"; import { Content, Flex, Skeleton, Title } from "@patternfly/react-core"; import Icon, { IconProps } from "~/components/layout/Icon"; -import NestedContent from "./NestedContent"; +import NestedContent from "~/components/core/NestedContent"; +import Text from "~/components/core/Text"; import { _ } from "~/i18n"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; +import WarningIcon from "@patternfly/react-icons/dist/esm/icons/exclamation-triangle-icon"; type SummaryProps = { + /** + * The name of the icon to display next to the title. + * Ignored when `hasIssues` is true (warning icon is shown instead). + */ icon: IconProps["name"]; - /** The label for the DescriptionListTerm */ + /** + * The label/title for the summary item. + * Typically rendered as a heading (h3) for semantic structure. + */ title: React.ReactNode; - /** The primary value of the item */ + /** + * The primary value or content of the summary item. + * Displayed below the title with emphasis when issues are present. + */ value: React.ReactNode; - /** Secondary information displayed below the content */ + /** + * Optional secondary information displayed below the primary value. + * Rendered in a smaller, subtle text style. + */ description?: React.ReactNode; - /** Whether to display the skeleton loading state */ + /** + * Whether to display skeleton loading placeholders instead of actual content. + * When true, shows loading states for both value and description. + */ isLoading?: boolean; + /** + * Whether a summary item has issues that require attention. + * When true: + * - Displays a warning icon instead of the regular icon + * - Applies bold styling to value and description + * - Adds warning color styling to the icon + */ + hasIssues?: boolean; }; const ValueSkeleton = () => ( @@ -48,7 +74,7 @@ const ValueSkeleton = () => ( /> ); -const DescritionSkeletons = () => ( +const DescriptionSkeletons = () => ( <> @@ -74,23 +100,45 @@ const DescritionSkeletons = () => ( * /> * ``` */ -const Summary = ({ title, icon, value, description, isLoading }: SummaryProps) => { +const Summary = ({ + title, + icon, + value, + description, + isLoading, + hasIssues = false, +}: SummaryProps) => { return (
- + {hasIssues ? ( + - {isLoading ? : {value}} {isLoading ? ( - + ) : ( - <> - {description && {description}} - + + {value} + + )} + {isLoading ? ( + + ) : ( + description && ( + + {description} + + ) )} diff --git a/web/src/components/core/Text.test.tsx b/web/src/components/core/Text.test.tsx index 5a0676f2d2..5be26fbd25 100644 --- a/web/src/components/core/Text.test.tsx +++ b/web/src/components/core/Text.test.tsx @@ -33,6 +33,18 @@ describe("Text", () => { expect(screen.getByText("Installer")).toBeInTheDocument(); }); + it("renders a 'span' HTML element when component is not given", () => { + plainRender(Installer); + const element = screen.getByText("Installer"); + expect(element.tagName).toBe("SPAN"); + }); + + it("renders a 'small' HTML element when component='small'", () => { + plainRender(Installer); + const element = screen.getByText("Installer"); + expect(element.tagName).toBe("SMALL"); + }); + it("applies bold style when isBold is true", () => { plainRender(Installer); expect(screen.getByText("Installer")).toHaveClass(textStyles.fontWeightBold); diff --git a/web/src/components/core/Text.tsx b/web/src/components/core/Text.tsx index b97e752a79..91b49cd4d1 100644 --- a/web/src/components/core/Text.tsx +++ b/web/src/components/core/Text.tsx @@ -30,6 +30,8 @@ type PageBreakPoints = ReturnType & React.PropsWithChildren<{ + /** The HTML element to use for wrapping given children */ + component?: "small" | "span"; /** Whether apply bold font weight */ isBold?: boolean; /** @@ -52,6 +54,7 @@ type TextProps = React.HTMLProps & * taking precedence. */ export default function Text({ + component = "span", isBold = false, srOnly = false, srOn, @@ -59,8 +62,10 @@ export default function Text({ children, ...props }: TextProps) { + const Wrapper = component; + return ( - {children} - + ); } diff --git a/web/src/components/overview/InstallationSettings.tsx b/web/src/components/overview/InstallationSettings.tsx index 935d5f2fe1..4837bae15d 100644 --- a/web/src/components/overview/InstallationSettings.tsx +++ b/web/src/components/overview/InstallationSettings.tsx @@ -28,6 +28,7 @@ import L10nSummary from "~/components/overview/L10nSummary"; import StorageSummary from "~/components/overview/StorageSummary"; import NetworkSummary from "~/components/overview/NetworkSummary"; import SoftwareSummary from "~/components/overview/SoftwareSummary"; +import RegistrationSummary from "~/components/overview/RegistrationSummary"; import { _ } from "~/i18n"; import a11yStyles from "@patternfly/react-styles/css/utilities/Accessibility/accessibility"; @@ -49,9 +50,10 @@ export default function InstallationSummarySection() {
+ - +
diff --git a/web/src/components/overview/RegistrationSummary.test.tsx b/web/src/components/overview/RegistrationSummary.test.tsx new file mode 100644 index 0000000000..b6dc3fa143 --- /dev/null +++ b/web/src/components/overview/RegistrationSummary.test.tsx @@ -0,0 +1,112 @@ +/* + * Copyright (c) [2026] 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 } from "@testing-library/react"; +import { installerRender, mockProduct } from "~/test-utils"; +import { useIssues } from "~/hooks/model/issue"; +import { useSystem } from "~/hooks/model/system/software"; +import RegistrationSummary from "./RegistrationSummary"; + +const mockUseSystem = jest.fn(); +const mockUseIssuesFn: jest.Mock> = jest.fn(); + +jest.mock("~/hooks/model/system/software", () => ({ + ...jest.requireActual("~/hooks/model/system/software"), + useSystem: (): jest.Mock> => mockUseSystem(), +})); + +jest.mock("~/hooks/model/issue", () => ({ + ...jest.requireActual("~/hooks/model/issue"), + useIssues: () => mockUseIssuesFn(), +})); + +describe("RegistrationSummary", () => { + beforeEach(() => { + mockUseIssuesFn.mockReturnValue([]); + }); + describe("when selected product is not registrable", () => { + beforeEach(() => { + mockProduct({ + id: "Tumbleweed", + name: "openSUSE Tumbleweed", + icon: "tumbleweed.svg", + description: "Tumbleweed description...", + registration: false, + }); + }); + + it("renders nothing", () => { + const { container } = installerRender(); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe("when selected product is registrable", () => { + beforeEach(() => { + mockProduct({ + id: "Tumbleweed", + name: "openSUSE Tumbleweed", + icon: "tumbleweed.svg", + description: "Tumbleweed description...", + registration: true, + }); + }); + + describe("and it is already registered", () => { + beforeEach(() => { + mockUseSystem.mockReturnValue({ + addons: [], + patterns: [], + repositories: [], + registration: { code: "123456789", addons: [] }, + }); + }); + + it("renders the registration summary with no issues and registered state", () => { + installerRender(); + // Check if the registration summary is displayed with the correct text + screen.getByText(/Registration/); + screen.getByText(/Registered/); + screen.getByText(/Using code ending in/); + screen.getByText("6789"); + }); + }); + + describe("but it is not registered yet", () => { + beforeEach(() => { + mockUseSystem.mockReturnValue({ + addons: [], + patterns: [], + repositories: [], + }); + }); + + it("renders the registration summary with no issues and registered state", () => { + installerRender(); + // Check if the registration summary is displayed with the correct text + screen.getByText(/Registration/); + screen.getByText(/Not registered yet/); + }); + }); + }); +}); diff --git a/web/src/components/overview/RegistrationSummary.tsx b/web/src/components/overview/RegistrationSummary.tsx new file mode 100644 index 0000000000..4674fa60ab --- /dev/null +++ b/web/src/components/overview/RegistrationSummary.tsx @@ -0,0 +1,85 @@ +/* + * Copyright (c) [2026] 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 Summary from "~/components/core/Summary"; +import Link from "~/components/core/Link"; +import Text from "~/components/core/Text"; +import { useProductInfo } from "~/hooks/model/config/product"; +import { REGISTRATION } from "~/routes/paths"; +import { _ } from "~/i18n"; +import { useSystem } from "~/hooks/model/system/software"; +import { useIssues } from "~/hooks/model/issue"; + +/** + * Internal component that renders the registration summary content. + * + * Separated from the parent to avoid unnecessary hook calls when the product + * doesn't support registration. + * + */ +const Content = () => { + const { registration } = useSystem(); + const issues = useIssues("software"); + const hasIssues = issues.find((i) => i.class === "software.register_system") !== undefined; + + // TRANSLATORS: Brief summary about the product registration. + // %s will be replaced with the last 4 digits of the registration code. + const [descriptionStart, descriptionEnd] = _("Using code ending in %s").split("%s"); + + return ( + + {_("Registration")} + + } + value={registration ? _("Registered") : _("Not registered yet")} + description={ + registration && ( + <> + {descriptionStart}{" "} + + {registration.code.slice(-4)} + {" "} + {descriptionEnd} + + ) + } + /> + ); +}; + +/** + * Renders a summary of product registration status. + * + * Only renders if the product supports registration. + */ +export default function RegistrationSummary() { + const product = useProductInfo(); + + if (!product || !product.registration) return null; + + return ; +} diff --git a/web/src/components/overview/StorageSummary.tsx b/web/src/components/overview/StorageSummary.tsx index 09fb1a2113..02ba325259 100644 --- a/web/src/components/overview/StorageSummary.tsx +++ b/web/src/components/overview/StorageSummary.tsx @@ -22,7 +22,7 @@ import React from "react"; import { sprintf } from "sprintf-js"; -import { HelperText, HelperTextItem } from "@patternfly/react-core"; +import { isEmpty } from "radashi"; import Summary from "~/components/core/Summary"; import Link from "~/components/core/Link"; import { useProgressTracking } from "~/hooks/use-progress-tracking"; @@ -91,11 +91,7 @@ const Value = () => { if (!availableDevices.length) return _("There are no disks available for the installation"); if (configIssues.length) { - return ( - - {_("Invalid settings")} - - ); + return _("Invalid settings"); } if (!model) return _("Using an advanced storage configuration"); @@ -160,9 +156,15 @@ const Description = () => { */ export default function StorageSummary() { const { loading } = useProgressTracking("storage"); + // FIXME: Refactor for avoid duplicating these checks about issues and actions + // TODO: extend tests for covering the hasIssues status + const actions = useActions(); + const issues = useIssues("storage"); + const configIssues = issues.filter((i) => i.class !== "proposal"); return ( diff --git a/web/src/components/product/ProductRegistrationPage.tsx b/web/src/components/product/ProductRegistrationPage.tsx index 6e5333bfc0..4cec023cb1 100644 --- a/web/src/components/product/ProductRegistrationPage.tsx +++ b/web/src/components/product/ProductRegistrationPage.tsx @@ -52,7 +52,7 @@ import { } from "~/components/core"; // import RegistrationExtension from "./RegistrationExtension"; import RegistrationCodeInput from "./RegistrationCodeInput"; -import { HOSTNAME } from "~/routes/paths"; +import { HOSTNAME, ROOT } from "~/routes/paths"; import { isEmpty } from "radashi"; import { mask } from "~/utils"; import { sprintf } from "sprintf-js"; @@ -62,6 +62,7 @@ import { useSystem } from "~/hooks/model/system/software"; import { useProduct, useProductInfo } from "~/hooks/model/config/product"; import { useIssues } from "~/hooks/model/issue"; import { patchConfig } from "~/api"; +import { Navigate } from "react-router"; const FORM_ID = "productRegistration"; const SERVER_LABEL = N_("Registration server"); @@ -432,7 +433,7 @@ export default function ProductRegistrationPage() { const showIssues = issues.find((i) => i.class === "software.register_system") !== undefined; // TODO: render something meaningful instead? "Product not registrable"? - if (!product || !product.registration) return; + if (!product || !product.registration) return ; return (