Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 61 additions & 13 deletions web/src/components/core/Summary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
Expand All @@ -48,7 +74,7 @@ const ValueSkeleton = () => (
/>
);

const DescritionSkeletons = () => (
const DescriptionSkeletons = () => (
<>
<Skeleton height="var(--pf-t--global--font--size--body--default)" />
<Skeleton width="70%" height="var(--pf-t--global--font--size--body--default)" />
Expand All @@ -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 (
<div>
<Flex gap={{ default: "gapXs" }} alignItems={{ default: "alignItemsCenter" }}>
<Icon name={icon} />
{hasIssues ? (
<WarningIcon
aria-hidden="true"
className={[textStyles.fontSizeMd, textStyles.textColorStatusWarning].join(" ")}
/>
) : (
<Icon name={icon} />
)}
<Title headingLevel="h3">{title}</Title>
</Flex>
<NestedContent margin="mxLg">
<NestedContent margin="myXs">
<Flex direction={{ default: "column" }} gap={{ default: "gapSm" }}>
{isLoading ? <ValueSkeleton /> : <Content isEditorial>{value}</Content>}
{isLoading ? (
<DescritionSkeletons />
<ValueSkeleton />
) : (
<>
{description && <small className={textStyles.textColorSubtle}>{description}</small>}
</>
<Content isEditorial>
<Text isBold={hasIssues}>{value}</Text>
</Content>
)}
{isLoading ? (
<DescriptionSkeletons />
) : (
description && (
<Text component="small" isBold={hasIssues} className={textStyles.textColorSubtle}>
{description}
</Text>
)
)}
</Flex>
</NestedContent>
Expand Down
12 changes: 12 additions & 0 deletions web/src/components/core/Text.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ describe("Text", () => {
expect(screen.getByText("Installer")).toBeInTheDocument();
});

it("renders a 'span' HTML element when component is not given", () => {
plainRender(<Text>Installer</Text>);
const element = screen.getByText("Installer");
expect(element.tagName).toBe("SPAN");
});

it("renders a 'small' HTML element when component='small'", () => {
plainRender(<Text component="small">Installer</Text>);
const element = screen.getByText("Installer");
expect(element.tagName).toBe("SMALL");
});

it("applies bold style when isBold is true", () => {
plainRender(<Text isBold>Installer</Text>);
expect(screen.getByText("Installer")).toHaveClass(textStyles.fontWeightBold);
Expand Down
9 changes: 7 additions & 2 deletions web/src/components/core/Text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ type PageBreakPoints = ReturnType<NonNullable<typeof Page.defaultProps.getBreakp

type TextProps = React.HTMLProps<HTMLSpanElement> &
React.PropsWithChildren<{
/** The HTML element to use for wrapping given children */
component?: "small" | "span";
/** Whether apply bold font weight */
isBold?: boolean;
/**
Expand All @@ -52,15 +54,18 @@ type TextProps = React.HTMLProps<HTMLSpanElement> &
* taking precedence.
*/
export default function Text({
component = "span",
isBold = false,
srOnly = false,
srOn,
className,
children,
...props
}: TextProps) {
const Wrapper = component;

return (
<span
<Wrapper
{...props}
className={[
className,
Expand All @@ -72,6 +77,6 @@ export default function Text({
.join(" ")}
>
{children}
</span>
</Wrapper>
);
}
4 changes: 3 additions & 1 deletion web/src/components/overview/InstallationSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -49,9 +50,10 @@ export default function InstallationSummarySection() {
<div className="installation-settings-summary">
<HostnameSummary />
<L10nSummary />
<RegistrationSummary />
<NetworkSummary />
<StorageSummary />
<SoftwareSummary />
<StorageSummary />
</div>
</NestedContent>
</>
Expand Down
112 changes: 112 additions & 0 deletions web/src/components/overview/RegistrationSummary.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof useIssues>> = jest.fn();

jest.mock("~/hooks/model/system/software", () => ({
...jest.requireActual("~/hooks/model/system/software"),
useSystem: (): jest.Mock<ReturnType<typeof useSystem>> => 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(<RegistrationSummary />);
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(<RegistrationSummary />);
// 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(<RegistrationSummary />);
// Check if the registration summary is displayed with the correct text
screen.getByText(/Registration/);
screen.getByText(/Not registered yet/);
});
});
});
});
85 changes: 85 additions & 0 deletions web/src/components/overview/RegistrationSummary.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Summary
hasIssues={hasIssues}
icon="app_registration"
title={
<Link to={REGISTRATION.root} variant="link" isInline>
{_("Registration")}
</Link>
}
value={registration ? _("Registered") : _("Not registered yet")}
description={
registration && (
<>
{descriptionStart}{" "}
<Text isBold>
<small>{registration.code.slice(-4)}</small>
</Text>{" "}
{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 <Content />;
}
Loading