diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 9ebb24e15b..1f3cbaa9aa 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -580,6 +580,16 @@ button:focus-visible { } } +span.in-quotes { + &::before { + content: open-quote; + } + + &::after { + content: close-quote; + } +} + // FIXME: make ir more generic, if possible, or even without CSS but not // rendering such a label if "storage instructions" are more than one .storage-structure:has(> li:nth-child(2)) span.action-text { diff --git a/web/src/components/core/ConfirmPage.tsx b/web/src/components/core/ConfirmPage.tsx new file mode 100644 index 0000000000..2d4216dc91 --- /dev/null +++ b/web/src/components/core/ConfirmPage.tsx @@ -0,0 +1,103 @@ +/* + * 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 from "react"; +import { Navigate } from "react-router"; +import { Button, Content, Flex, Split } from "@patternfly/react-core"; +import Page from "~/components/core/Page"; +import Text from "~/components/core/Text"; +import Details from "~/components/core/Details"; +import HostnameDetailsItem from "~/components/system/HostnameDetailsItem"; +import L10nDetailsItem from "~/components/l10n/L10nDetailsItem"; +import StorageDetailsItem from "~/components/storage/StorageDetailsItem"; +import NetworkDetailsItem from "~/components/network/NetworkDetailsItem"; +import SoftwareDetailsItem from "~/components/software/SoftwareDetailsItem"; +import PotentialDataLossAlert from "~/components/storage/PotentialDataLossAlert"; +import { startInstallation } from "~/model/manager"; +import { useProduct } from "~/hooks/model/config"; +import { PRODUCT } from "~/routes/paths"; +import { useDestructiveActions } from "~/hooks/use-destructive-actions"; +import { _ } from "~/i18n"; + +export default function ConfirmPage() { + const product = useProduct(); + const { actions } = useDestructiveActions(); + const hasDestructiveActions = actions.length > 0; + + if (!product) { + return ; + } + + // TRANSLATORS: title shown in the confirmation page before + // starting the installation. %s will be replaced with the product name. + const [titleStart, titleEnd] = _("Start %s installation?").split("%s"); + + return ( + + + + + {titleStart} {product.name} {titleEnd} + + + { + // TRANSLATORS: Part of the introductory text shown in the confirmation page before + // starting the installation. + _( + "Review the summary below. If anything seems incorrect or you have doubts, go back and adjust the settings before proceeding.", + ) + } + + +
+ + + + + +
+ + + + {_("Go back")} + +
+
+
+ ); +} diff --git a/web/src/components/core/Details.test.tsx b/web/src/components/core/Details.test.tsx new file mode 100644 index 0000000000..3619eda7c0 --- /dev/null +++ b/web/src/components/core/Details.test.tsx @@ -0,0 +1,165 @@ +/* + * 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 from "react"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import Details from "./Details"; + +describe("Details", () => { + describe("basic rendering", () => { + it("renders a PatternFly description list", () => { + const { container } = plainRender( +
+ John Doe +
, + ); + + const descriptionList = container.querySelector("dl"); + expect(descriptionList).toBeInTheDocument(); + expect(descriptionList.classList).toContain("pf-v6-c-description-list"); + }); + + it("renders multiple items", () => { + plainRender( +
+ John Doe + john@example.com + Developer +
, + ); + + screen.getByText("Name"); + screen.getByText("John Doe"); + screen.getByText("Email"); + screen.getByText("john@example.com"); + screen.getByText("Role"); + screen.getByText("Developer"); + }); + + it("renders with no items", () => { + const { container } = plainRender(
); + + const descriptionList = container.querySelector("dl"); + expect(descriptionList).toBeInTheDocument(); + expect(descriptionList).toBeEmptyDOMElement(); + }); + }); + + describe("Details.Item", () => { + it("renders label and children correctly", () => { + plainRender( +
+ AMD Ryzen 7 +
, + ); + + screen.getByText("CPU"); + screen.getByText("AMD Ryzen 7"); + }); + + describe("PatternFly props passthrough", () => { + it("passes props to DescriptionList", () => { + const { container } = plainRender( +
+ John +
, + ); + + const descriptionList = container.querySelector("dl"); + expect(descriptionList).toHaveClass("pf-m-horizontal"); + expect(descriptionList).toHaveClass("pf-m-compact"); + }); + }); + + describe("termProps and descriptionProps", () => { + it("passes termProps to DescriptionListTerm", () => { + const { container } = plainRender( +
+ + John + +
, + ); + + const term = container.querySelector("dt"); + expect(term).toHaveClass("custom-term-class"); + }); + + it("passes descriptionProps to DescriptionListDescription", () => { + const { container } = plainRender( +
+ + John + +
, + ); + + const description = container.querySelector("dd"); + expect(description).toHaveClass("custom-desc-class"); + }); + }); + }); + + describe("Details.StackItem", () => { + it("renders given data within an opinionated flex layout", () => { + const { container } = plainRender( +
+ Use device vdd (20 GiB)} + description="Potential data loss" + /> +
, + ); + + const flexContainer = container.querySelector( + '[class*="pf-v"][class*="-l-flex"][class*=pf-m-column]', + ); + + screen.getByText("Storage"); + screen.getByRole("link", { name: /Use device vdd/i }); + const small = container.querySelector("small"); + expect(small).toHaveTextContent("Potential data loss"); + expect(small).toHaveClass(/pf-v.-u-text-color-subtle/); + }); + + it("renders skeleton placeholders instead of content when isLoading is true", () => { + const { container } = plainRender( +
+ Use device vdd (20 GiB)} + description="Potential data loss" + isLoading + /> +
, + ); + + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect(screen.queryByText("Use device vdd (20 GiB)")).not.toBeInTheDocument(); + expect(screen.queryByText("Potential data loss")).not.toBeInTheDocument(); + const skeletons = container.querySelectorAll('[class*="pf-v"][class*="-c-skeleton"]'); + expect(skeletons.length).toBe(2); + }); + }); +}); diff --git a/web/src/components/core/Details.tsx b/web/src/components/core/Details.tsx new file mode 100644 index 0000000000..d84107adf7 --- /dev/null +++ b/web/src/components/core/Details.tsx @@ -0,0 +1,131 @@ +/* + * 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 from "react"; +import { + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, + Flex, + Skeleton, +} from "@patternfly/react-core"; +import type { + DescriptionListTermProps, + DescriptionListDescriptionProps, + DescriptionListProps, +} from "@patternfly/react-core"; +import { _ } from "~/i18n"; + +import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; + +type ItemProps = { + /** The label/term for this field */ + label: DescriptionListTermProps["children"]; + /** The value/description for this field */ + children: DescriptionListDescriptionProps["children"]; + /** Additional props passed to the DescriptionListTerm component */ + termProps?: Omit; + /** Additional props passed to the DescriptionListDescription component */ + descriptionProps?: Omit; +}; + +/** + * A single item in a `Details` description list. + * + * Wraps a PatternFly `DescriptionListGroup` with `DescriptionListTerm` and + * `DescriptionListDescription`. + */ +const Item = ({ label, children, termProps = {}, descriptionProps = {} }: ItemProps) => { + return ( + + {label} + {children} + + ); +}; + +type SummaryItemProps = { + /** The label for the DescriptionListTerm */ + label: React.ReactNode; + /** The primary value of the item */ + content: React.ReactNode; + /** Secondary information displayed below the content */ + description?: React.ReactNode; + /** Whether to display the skeleton loading state */ + isLoading?: boolean; +}; + +/** + * A layout-opinionated item for `Details`. + * + * Used for rendering items in the Agama overview and confirmation pages, where + * a single term has to be rendered with a primary value and an optional + * description as well as a consistent "loading skeleton states" when isLoading + * is true. + */ +const StackItem = ({ label, content, description, isLoading }: SummaryItemProps) => { + return ( + + + {isLoading ? ( + <> + + + + ) : ( + <> + {content} + {description && {description}} + + )} + + + ); +}; + +/** + * An abstraction over PatternFly's `DescriptionList`. + * + * Provides a simpler, more declarative API for building description lists using + * the compound component pattern with `Details.Item`. + * + * @example + * ```tsx + *
+ * Lenovo ThinkPad P14s Gen 4 + * AMD Ryzen™ 7 × 16 + * + * + * + *
+ * ``` + */ +const Details = ({ children, ...props }: DescriptionListProps) => { + return {children}; +}; + +Details.Item = Item; +Details.StackItem = StackItem; + +export default Details; +export type { ItemProps as DetailsItemProps }; diff --git a/web/src/components/core/InstallButton.test.tsx b/web/src/components/core/InstallButton.test.tsx index e669546911..1363ecd240 100644 --- a/web/src/components/core/InstallButton.test.tsx +++ b/web/src/components/core/InstallButton.test.tsx @@ -41,15 +41,6 @@ jest.mock("~/hooks/model/issue", () => ({ useIssues: () => mockIssues(), })); -const clickInstallButton = async () => { - const { user } = installerRender(); - const button = await screen.findByRole("button", { name: "Install" }); - await user.click(button); - - const dialog = screen.getByRole("dialog", { name: "Confirm Installation" }); - return { user, dialog }; -}; - describe("InstallButton", () => { describe("when there are installation issues", () => { beforeEach(() => { @@ -73,13 +64,12 @@ describe("InstallButton", () => { screen.getByRole("tooltip", { name: /Not possible with the current setup/ }); }); - it("triggers the onClickWithIssues callback without rendering the confirmation dialog", async () => { + it("triggers the onClickWithIssues callback", async () => { const onClickWithIssuesFn = jest.fn(); const { user } = installerRender(); const button = screen.getByRole("button", { name: /Install/ }); await user.click(button); expect(onClickWithIssuesFn).toHaveBeenCalled(); - await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); }); }); @@ -100,15 +90,6 @@ describe("InstallButton", () => { ).toBeNull(); }); - it("renders a confirmation dialog when clicked without triggering the onClickWithIssues callback", async () => { - const onClickWithIssuesFn = jest.fn(); - const { user } = installerRender(); - const button = await screen.findByRole("button", { name: "Install" }); - await user.click(button); - expect(onClickWithIssuesFn).not.toHaveBeenCalled(); - screen.getByRole("dialog", { name: "Confirm Installation" }); - }); - describe.each([ ["login", ROOT.login], ["product selection", PRODUCT.changeProduct], @@ -126,76 +107,4 @@ describe("InstallButton", () => { }); }); }); - - describe("when there are only non-critical issues", () => { - beforeEach(() => { - mockIssues.mockReturnValue([ - { - description: "Fake warning", - class: "generic", - details: "Fake Issue details", - scope: "product", - }, - ] as Issue[]); - }); - - it("renders the button without any additional information", async () => { - const { user, container } = installerRender(); - const button = screen.getByRole("button", { name: "Install" }); - // Renders nothing else - const icon = container.querySelector("svg"); - expect(icon).toBeNull(); - await user.hover(button); - expect( - screen.queryByRole("tooltip", { name: /Not possible with the current setup/ }), - ).toBeNull(); - }); - }); -}); - -describe("InstallConfirmationPopup", () => { - it("closes the dialog without triggering installation if user press {enter} before 'Continue' gets the focus", async () => { - const { user, dialog } = await clickInstallButton(); - const continueButton = within(dialog).getByRole("button", { name: "Continue" }); - expect(continueButton).not.toHaveFocus(); - await user.keyboard("{enter}"); - expect(mockStartInstallationFn).not.toHaveBeenCalled(); - await waitFor(() => { - expect(dialog).not.toBeInTheDocument(); - }); - }); - - it("closes the dialog and triggers installation if user {enter} when 'Continue' has the focus", async () => { - const { user, dialog } = await clickInstallButton(); - const continueButton = within(dialog).getByRole("button", { name: "Continue" }); - expect(continueButton).not.toHaveFocus(); - await user.keyboard("{tab}"); - expect(continueButton).toHaveFocus(); - await user.keyboard("{enter}"); - expect(mockStartInstallationFn).toHaveBeenCalled(); - await waitFor(() => { - expect(dialog).not.toBeInTheDocument(); - }); - }); - - it("closes the dialog and triggers installation if user clicks on 'Continue'", async () => { - const { user, dialog } = await clickInstallButton(); - const continueButton = within(dialog).getByRole("button", { name: "Continue" }); - await user.click(continueButton); - expect(mockStartInstallationFn).toHaveBeenCalled(); - await waitFor(() => { - expect(dialog).not.toBeInTheDocument(); - }); - }); - - it("closes the dialog without triggering installation if the user clicks on 'Cancel'", async () => { - const { user, dialog } = await clickInstallButton(); - const cancelButton = within(dialog).getByRole("button", { name: "Cancel" }); - await user.click(cancelButton); - expect(mockStartInstallationFn).not.toHaveBeenCalled(); - - await waitFor(() => { - expect(dialog).not.toBeInTheDocument(); - }); - }); }); diff --git a/web/src/components/core/InstallButton.tsx b/web/src/components/core/InstallButton.tsx index 09ef093244..8b25a16669 100644 --- a/web/src/components/core/InstallButton.tsx +++ b/web/src/components/core/InstallButton.tsx @@ -20,52 +20,15 @@ * find current contact information at www.suse.com. */ -import React, { useId, useState } from "react"; -import { Button, ButtonProps, Stack, Tooltip, TooltipProps } from "@patternfly/react-core"; -import { Popup } from "~/components/core"; -import { startInstallation } from "~/model/manager"; +import React, { useId } from "react"; +import { Button, ButtonProps, Tooltip, TooltipProps } from "@patternfly/react-core"; import { useIssues } from "~/hooks/model/issue"; -import { useLocation } from "react-router"; -import { SIDE_PATHS } from "~/routes/paths"; +import { useLocation, useNavigate } from "react-router"; +import { ROOT, SIDE_PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; import { Icon } from "../layout"; import { isEmpty } from "radashi"; -/** - * List of paths where the InstallButton must not be shown. - * - * Apart from obvious login and installation paths, it does not make sense to - * show the button neither, when the user is about to change the product, - * defining the root authentication for the first time, nor when the installer - * is setting the chosen product. - * */ - -const InstallConfirmationPopup = ({ onAccept, onClose }) => { - return ( - - -

- {_( - "If you continue, partitions on your hard disk will be modified \ -according to the provided installation settings.", - )} -

-

{_("Please, cancel and check the settings if you are unsure.")}

-
- - - {/* TRANSLATORS: button label */} - {_("Continue")} - - - {/* TRANSLATORS: button label */} - {_("Cancel")} - - -
- ); -}; - /** * Installation button * @@ -79,19 +42,15 @@ const InstallButton = ( const labelId = useId(); const tooltipId = useId(); const issues = useIssues(); - const [isOpen, setIsOpen] = useState(false); + const navigate = useNavigate(); const location = useLocation(); const hasIssues = !isEmpty(issues); if (SIDE_PATHS.includes(location.pathname)) return; + const navigateToConfirmation = () => navigate(ROOT.confirm); + const { onClickWithIssues, ...buttonProps } = props; - const open = async () => setIsOpen(true); - const close = () => setIsOpen(false); - const onAccept = () => { - close(); - startInstallation(); - }; // TRANSLATORS: The install button label const buttonText = _("Install"); @@ -113,14 +72,13 @@ const InstallButton = ( variant="control" className="agm-install-button" {...buttonProps} - onClick={hasIssues ? onClickWithIssues : open} + onClick={hasIssues ? onClickWithIssues : navigateToConfirmation} icon={hasIssues && } iconPosition="end" > {buttonText} - {isOpen && } ); }; diff --git a/web/src/components/core/ProgressBackdrop.tsx b/web/src/components/core/ProgressBackdrop.tsx index 4b9ca39f5c..0e4ced567e 100644 --- a/web/src/components/core/ProgressBackdrop.tsx +++ b/web/src/components/core/ProgressBackdrop.tsx @@ -20,16 +20,15 @@ * find current contact information at www.suse.com. */ -import React, { useEffect, useState } from "react"; +import React from "react"; import { Alert, Backdrop, Flex, FlexItem, Spinner } from "@patternfly/react-core"; -import { concat, isEmpty } from "radashi"; +import { concat } from "radashi"; import { sprintf } from "sprintf-js"; import { COMMON_PROPOSAL_KEYS } from "~/hooks/model/proposal"; -import { useStatus } from "~/hooks/model/status"; -import useTrackQueriesRefetch from "~/hooks/use-track-queries-refetch"; import type { Scope } from "~/model/status"; import { _ } from "~/i18n"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; +import { useProgressTracking } from "~/hooks/use-progress-tracking"; /** * Props for the ProgressBackdrop component. @@ -86,32 +85,11 @@ export default function ProgressBackdrop({ scope, ensureRefetched, }: ProgressBackdropProps): React.ReactNode { - const { progresses: tasks } = useStatus(); - const [isBlocked, setIsBlocked] = useState(false); - const [progressFinishedAt, setProgressFinishedAt] = useState(null); - const progress = !isEmpty(scope) && tasks.find((t) => t.scope === scope); - const { startTracking } = useTrackQueriesRefetch( + const { loading: isBlocked, progress } = useProgressTracking( + scope, concat(COMMON_PROPOSAL_KEYS, ensureRefetched), - (_, completedAt) => { - if (completedAt > progressFinishedAt) { - setIsBlocked(false); - setProgressFinishedAt(null); - } - }, ); - useEffect(() => { - if (!progress && isBlocked && !progressFinishedAt) { - setProgressFinishedAt(Date.now()); - startTracking(); - } - }, [progress, isBlocked, progressFinishedAt, startTracking]); - - if (progress && !isBlocked) { - setIsBlocked(true); - setProgressFinishedAt(null); - } - if (!isBlocked) return null; return ( diff --git a/web/src/components/l10n/L10nDetailsItem.tsx b/web/src/components/l10n/L10nDetailsItem.tsx new file mode 100644 index 0000000000..3e318e7c8a --- /dev/null +++ b/web/src/components/l10n/L10nDetailsItem.tsx @@ -0,0 +1,67 @@ +/* + * 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 from "react"; +import Details from "~/components/core/Details"; +import Link from "~/components/core/Link"; +import { useProposal } from "~/hooks/model/proposal/l10n"; +import { useSystem } from "~/hooks/model/system/l10n"; +import { L10N } from "~/routes/paths"; +import { sprintf } from "sprintf-js"; +import { _ } from "~/i18n"; + +export default function L10nDetailsItem({ withoutLink = false }: { withoutLink?: boolean }) { + const l10nProposal = useProposal(); + const l10nSystem = useSystem(); + const locale = + l10nProposal.locale && l10nSystem.locales.find((l) => l.id === l10nProposal.locale); + const keymap = + l10nProposal.keymap && l10nSystem.keymaps.find((k) => k.id === l10nProposal.keymap); + const timezone = + l10nProposal.timezone && l10nSystem.timezones.find((t) => t.id === l10nProposal.timezone); + + // TRANSLATORS: Summary of the selected language and territory. + // %1$s is the language name (e.g. "Spanish"). + // %2$s is the territory/region name (e.g. "Spain"). + const title = sprintf(_("%1$s (%2$s)"), locale.language, locale.territory); + + return ( + + {title} + + ) + } + description={ + // TRANSLATORS: Additional details shown under the language selection. + // %1$s is the keyboard layout name (e.g. "Spanish"). + // %2$s is the time zone identifier (e.g. "Atlantic/Canary"). + sprintf(_("%1$s keyboard - %2$s timezone"), keymap.description, timezone.id) + } + /> + ); +} diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 589a0d0528..c46e7d91b0 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -148,17 +148,17 @@ export default function Header({ - - - - - - {showInstallerOptions && ( - + )} + + + + + + diff --git a/web/src/components/network/FormattedIpsList.test.tsx b/web/src/components/network/FormattedIpsList.test.tsx new file mode 100644 index 0000000000..48069d31a5 --- /dev/null +++ b/web/src/components/network/FormattedIpsList.test.tsx @@ -0,0 +1,95 @@ +/* + * 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 version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 } from "~/test-utils"; +import FormattedIPsList from "./FormattedIpsList"; +import { NETWORK } from "~/routes/paths"; + +let mockUseIpAddressesFn: jest.Mock = jest.fn(); + +jest.mock("~/hooks/model/system/network", () => ({ + ...jest.requireActual("~/hooks/model/system/network"), + useIpAddresses: () => mockUseIpAddressesFn(), +})); + +describe("FormattedIPsList", () => { + it("renders nothing when no IP addresses are available", () => { + mockUseIpAddressesFn.mockReturnValue([]); + + const { container } = installerRender(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders a single IP address when only one address is available", () => { + mockUseIpAddressesFn.mockReturnValue(["192.168.1.1"]); + + const { rerender } = installerRender(); + screen.getByText("192.168.1.1"); + + mockUseIpAddressesFn.mockReturnValue(["fe80::1"]); + rerender(); + screen.getByText("fe80::1"); + }); + + it("does not render a link when exactly two addresses are available", () => { + mockUseIpAddressesFn.mockReturnValue(["192.168.1.1", "fe80::1"]); + + installerRender(); + screen.getByText("192.168.1.1 and fe80::1"); + expect(screen.queryAllByRole("link")).toEqual([]); + }); + + it("renders a link when there are more than two IP addresses", () => { + mockUseIpAddressesFn.mockReturnValue(["192.168.1.1", "fe80::1", "192.168.1.2", "fe80::2"]); + + installerRender(); + const moreLink = screen.getByRole("link", { name: "2 more" }); + expect(moreLink).toHaveAttribute("href", NETWORK.root); + }); + + it("renders a link when there are multiple IP addresses of the same type", () => { + mockUseIpAddressesFn.mockReturnValue(["192.168.1.1", "192.168.1.2"]); + + const { rerender } = installerRender(); + screen.getByText(/192\.168\.1\.1/); + const moreLink = screen.getByRole("link", { name: "1 more" }); + expect(moreLink).toHaveAttribute("href", NETWORK.root); + + mockUseIpAddressesFn.mockReturnValue(["fe80::1", "fe80::2"]); + rerender(); + screen.getByText(/fe80::1/); + const moreLink2 = screen.getByRole("link", { name: "1 more" }); + expect(moreLink2).toHaveAttribute("href", NETWORK.root); + }); + + it("renders only the first IP of each type when there are multiple addresses of each type", () => { + mockUseIpAddressesFn.mockReturnValue(["192.168.1.1", "fe80::1", "192.168.1.2", "fe80::2"]); + + installerRender(); + screen.getByText(/192\.168\.1\.1/); + screen.getByText(/fe80::1/); + expect(screen.queryByText(/192\.168\.1\.2/)).not.toBeInTheDocument(); + expect(screen.queryByText(/fe80::2/)).not.toBeInTheDocument(); + screen.getByRole("link", { name: "2 more" }); + }); +}); diff --git a/web/src/components/network/FormattedIpsList.tsx b/web/src/components/network/FormattedIpsList.tsx new file mode 100644 index 0000000000..6df24d3863 --- /dev/null +++ b/web/src/components/network/FormattedIpsList.tsx @@ -0,0 +1,106 @@ +/* + * Copyright (c) [2025-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 { IPv4, IPv6 } from "ipaddr.js"; +import { isEmpty } from "radashi"; +import { sprintf } from "sprintf-js"; +import Link from "~/components/core/Link"; +import { NETWORK } from "~/routes/paths"; +import { useIpAddresses } from "~/hooks/model/system/network"; +import { _ } from "~/i18n"; + +/** + * Displays a formatted list of IP addresses from connected devices. + * + * Shows up to two IP addresses (one IPv4 and one IPv6 when available) in a + * compact format. If there are additional addresses beyond the first two, + * displays a "X more" link that navigates to the network page where all + * addresses can be viewed. + * + * Display formats: + * - Single IP: "192.168.1.1" + * - Two IPs: "192.168.1.1 and fe80::1" + * - With extras: "192.168.1.1, fe80::1 and 2 more" (where "2 more" is a + * clickable link) + */ +export default function FormattedIPsList() { + const addresses = useIpAddresses({ formatted: true }); + let firstIPv4: string | undefined; + let firstIPv6: string | undefined; + const rest: string[] = []; + + // Iterate over the addresses to find firstIPv4, firstIPv6, and the rest + for (const address of addresses) { + if (IPv4.isValid(address) && !firstIPv4) { + firstIPv4 = address; + } else if (IPv6.isValid(address) && !firstIPv6) { + firstIPv6 = address; + } else { + rest.push(address); + } + } + + if (!isEmpty(rest)) { + let text: string; + let params: (string | number)[]; + + if (firstIPv4 && firstIPv6) { + // TRANSLATORS: Displays both IPv4 and IPv6 addresses with count of + // additional IPs (e.g., "192.168.122.237, fe80::5054:ff:fe46:2af9 and 1 + // more"). %1$s is the IPv4 address, %2$s is the IPv6 address, and %3$d + // is the number of remaining IPs. The text wrapped in square brackets [] + // is displayed as a link. Keep the brackets to ensure the link works + // correctly. + text = _("%1$s, %2$s and [%3$d more]"); + params = [firstIPv4, firstIPv6, rest.length]; + } else { + // TRANSLATORS: Displays a single IP address (either IPv4 or IPv6) with + // count of additional IPs (e.g., "192.168.122.237 and [2 more]"). %1$s is + // the IP address and %2$d is the number of remaining IPs. The text + // wrapped in square brackets [] is displayed as a link. Keep the brackets + // to ensure the link works correctly. + text = _("%1$s and [%2$d more]"); + params = [firstIPv4 || firstIPv6, rest.length]; + } + + const [textStart, link, textEnd] = sprintf(text, ...params).split(/[[\]]/); + + return ( + <> + {textStart}{" "} + + {link} + {" "} + {textEnd} + + ); + } + + if (firstIPv4 && firstIPv6) { + // TRANSLATORS: Displays first IPv4 and IPv6 when both are present. + // %1$s is the first IPv4 and %2$s is the first IPv6. + return sprintf(_("%1$s and %2$s"), firstIPv4, firstIPv6); + } + + return firstIPv4 || firstIPv6; +} diff --git a/web/src/components/network/NetworkDetailsItem.test.tsx b/web/src/components/network/NetworkDetailsItem.test.tsx new file mode 100644 index 0000000000..ae1715f3c8 --- /dev/null +++ b/web/src/components/network/NetworkDetailsItem.test.tsx @@ -0,0 +1,237 @@ +/* + * 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 } from "~/test-utils"; +import { NetworkStatus } from "~/hooks/model/system/network"; +import NetworkDetailsItem from "./NetworkDetailsItem"; + +const mockUseNetworkStatusFn = jest.fn(); + +jest.mock("~/hooks/model/system/network", () => ({ + ...jest.requireActual("~/hooks/model/system/network"), + useNetworkStatus: () => mockUseNetworkStatusFn(), +})); + +describe("NetworkDetailsItem", () => { + describe("when network status is NOT_CONFIGURED", () => { + it("renders 'Not configured'", () => { + mockUseNetworkStatusFn.mockReturnValue({ + status: NetworkStatus.NOT_CONFIGURED, + persistentConnections: [], + }); + installerRender(); + screen.getByText("Not configured"); + }); + }); + + describe("when network status is NO_PERSISTENT", () => { + it("renders 'Installation only' and a short explanation", () => { + mockUseNetworkStatusFn.mockReturnValue({ + status: NetworkStatus.NO_PERSISTENT, + persistentConnections: [], + }); + installerRender(); + screen.getByText("Installation only"); + screen.getByText("System will have no network connections"); + }); + }); + + describe("when network status is AUTO", () => { + it("renders mode and singular form summary when there is one connection", () => { + mockUseNetworkStatusFn.mockReturnValue({ + status: NetworkStatus.AUTO, + persistentConnections: [{ addresses: [] }], + }); + installerRender(); + screen.getByText("Auto"); + screen.getByText("Configured with 1 connection"); + }); + + it("renders mode and plural form summary when multiple connections", () => { + mockUseNetworkStatusFn.mockReturnValue({ + status: NetworkStatus.AUTO, + persistentConnections: [{ addresses: [] }, { addresses: [] }], + }); + installerRender(); + screen.getByText("Auto"); + screen.getByText("Configured with 2 connections"); + }); + }); + + describe("when network status is MANUAL", () => { + it("renders mode and single IP for single connection with one address", () => { + mockUseNetworkStatusFn.mockReturnValue({ + status: NetworkStatus.MANUAL, + persistentConnections: [{ addresses: [{ address: "192.168.1.100", prefix: 24 }] }], + }); + installerRender(); + screen.getByText("Manual"); + screen.getByText("192.168.1.100"); + }); + + it("renders mode and all IPs for single connection with two addresses", () => { + mockUseNetworkStatusFn.mockReturnValue({ + status: NetworkStatus.MANUAL, + persistentConnections: [ + { + addresses: [ + { address: "192.168.1.100", prefix: 24 }, + { address: "192.168.1.101", prefix: 24 }, + ], + }, + ], + }); + installerRender(); + screen.getByText("Manual"); + screen.getByText("192.168.1.100 and 192.168.1.101"); + }); + + it("renders mode and the first IP and count for single connection with multiple addresses", () => { + mockUseNetworkStatusFn.mockReturnValue({ + status: NetworkStatus.MANUAL, + persistentConnections: [ + { + addresses: [ + { address: "192.168.1.100", prefix: 24 }, + { address: "192.168.1.101", prefix: 24 }, + { address: "192.168.1.102", prefix: 24 }, + ], + }, + ], + }); + installerRender(); + screen.getByText("Manual"); + screen.getByText("192.168.1.100 and 2 others"); + }); + + it("renders mode and connection count and IP summary for multiple connections", () => { + mockUseNetworkStatusFn.mockReturnValue({ + status: NetworkStatus.MANUAL, + persistentConnections: [ + { addresses: [{ address: "192.168.1.100", prefix: 24 }] }, + { addresses: [{ address: "10.0.0.5", prefix: 8 }] }, + ], + }); + installerRender(); + screen.getByText("Manual"); + screen.getByText("Using 2 connections with 192.168.1.100 and 10.0.0.5"); + }); + + it("renders mode and connection count with IP summary when multiple connections have more than two IPs", () => { + mockUseNetworkStatusFn.mockReturnValue({ + status: NetworkStatus.MANUAL, + persistentConnections: [ + { + addresses: [ + { address: "192.168.1.100", prefix: 24 }, + { address: "192.168.1.101", prefix: 24 }, + ], + }, + { addresses: [{ address: "10.0.0.5", prefix: 8 }] }, + ], + }); + installerRender(); + screen.getByText("Manual"); + screen.getByText("Using 2 connections with 192.168.1.100 and 2 others"); + }); + }); + + describe("when network status is MIXED", () => { + it("renders mode and 'DHCP' and single IP when there is only one static IP", () => { + mockUseNetworkStatusFn.mockReturnValue({ + status: NetworkStatus.MIXED, + persistentConnections: [{ addresses: [{ address: "192.168.1.100", prefix: 24 }] }], + }); + installerRender(); + screen.getByText("Auto and manual"); + screen.getByText("DHCP and 192.168.1.100"); + }); + + it("renders mode and 'DHCP' with two IPs when there are up to two static IPs", () => { + mockUseNetworkStatusFn.mockReturnValue({ + status: NetworkStatus.MIXED, + persistentConnections: [ + { + addresses: [ + { address: "192.168.1.100", prefix: 24 }, + { address: "192.168.1.101", prefix: 24 }, + ], + }, + ], + }); + installerRender(); + screen.getByText("Auto and manual"); + screen.getByText("DHCP, 192.168.1.100 and 192.168.1.101"); + }); + + it("renders mode and 'DHCP' with IP summary when there are more than two static IPs", () => { + mockUseNetworkStatusFn.mockReturnValue({ + status: NetworkStatus.MIXED, + persistentConnections: [ + { + addresses: [ + { address: "192.168.1.100", prefix: 24 }, + { address: "192.168.1.101", prefix: 24 }, + { address: "192.168.1.102", prefix: 24 }, + ], + }, + ], + }); + installerRender(); + screen.getByText("Auto and manual"); + screen.getByText("DHCP, 192.168.1.100 and 2 others"); + }); + + it("renders mode and connection count with 'DHCP' and IP for multiple connections", () => { + mockUseNetworkStatusFn.mockReturnValue({ + status: NetworkStatus.MIXED, + persistentConnections: [ + { addresses: [{ address: "192.168.1.100", prefix: 24 }] }, + { addresses: [] }, + ], + }); + installerRender(); + screen.getByText("Auto and manual"); + screen.getByText("Using 2 connections with DHCP and 192.168.1.100"); + }); + + it("renders mode and connection count with 'DHCP' and IP summary when there are many static IPs", () => { + mockUseNetworkStatusFn.mockReturnValue({ + status: NetworkStatus.MIXED, + persistentConnections: [ + { + addresses: [ + { address: "192.168.1.100", prefix: 24 }, + { address: "192.168.1.101", prefix: 24 }, + ], + }, + { addresses: [{ address: "10.0.0.5", prefix: 8 }] }, + ], + }); + installerRender(); + screen.getByText("Auto and manual"); + screen.getByText("Using 2 connections with DHCP, 192.168.1.100 and 2 others"); + }); + }); +}); diff --git a/web/src/components/network/NetworkDetailsItem.tsx b/web/src/components/network/NetworkDetailsItem.tsx new file mode 100644 index 0000000000..766280e6ac --- /dev/null +++ b/web/src/components/network/NetworkDetailsItem.tsx @@ -0,0 +1,159 @@ +/* + * Copyright (c) [2025-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 { sprintf } from "sprintf-js"; +import Details from "~/components/core/Details"; +import Link from "~/components/core/Link"; +import { NETWORK } from "~/routes/paths"; +import { NetworkStatus, useNetworkStatus } from "~/hooks/model/system/network"; +import { formatIp } from "~/utils/network"; +import { _, n_ } from "~/i18n"; +import type { Connection } from "~/types/network"; + +type NetworkStatusValue = (typeof NetworkStatus)[keyof typeof NetworkStatus]; + +type DescriptionProps = { + status: NetworkStatusValue; + connections: Connection[]; +}; + +type TitleProps = { + status: NetworkStatusValue; +}; + +/** + * Helper component that renders a brief description of the current network + * configuration, based on the provided status and network connections + */ +const Description = ({ status, connections }: DescriptionProps) => { + const connectionsQty = connections.length; + const staticIps = connections + .flatMap((c) => c.addresses) + .map((a) => formatIp(a, { removePrefix: true })); + const staticIpsQty = staticIps.length; + + // Format IP list once + const ipList = (() => { + if (staticIpsQty === 0) return ""; + if (staticIpsQty === 1) return staticIps[0]; + // TRANSLATORS: Displays two IP addresses. E.g., "192.168.1.1 and 192.168.1.2" + // %s will be replaced by IP addresses + if (staticIpsQty === 2) return sprintf(_("%s and %s"), staticIps[0], staticIps[1]); + // TRANSLATORS: IPs summary when there are more than 2 static IP addresses. + // E.g., "192.168.1.1 and 2 others" + // %s is replaced by an IP address (e.g., "192.168.1.1") + // %d is replaced by the count of remaining IPs (e.g., "2") + return sprintf(_("%s and %d others"), staticIps[0], staticIpsQty - 1); + })(); + + switch (status) { + case NetworkStatus.NOT_CONFIGURED: + return null; + case NetworkStatus.NO_PERSISTENT: + // TRANSLATORS: Description shown when network connections are configured + // only for installation and won't be available in the installed system + return _("System will have no network connections"); + // return _("No connections will be copied to the installed system"); + case NetworkStatus.MANUAL: { + if (connectionsQty === 1) return ipList; + // TRANSLATORS: Summary for multiple manual network connections. E.g., + // "Using 3 connections with 192.168.1.1 and 2 others" + // %d is replaced by the number of connections (e.g., "3") + // %s is replaced by IP address summary (e.g., "192.168.1.1 and 2 others") + return sprintf(_("Using %d connections with %s"), connectionsQty, ipList); + } + case NetworkStatus.MIXED: { + // TRANSLATORS: Summary combining DHCP (automatic) with static IP + // addresses %s is replaced by IP address(es), e.g., "192.168.1.1" or + // "192.168.1.1 and 2 others" + const dhcpAndIps = + staticIpsQty === 1 ? sprintf(_("DHCP and %s"), ipList) : sprintf(_("DHCP, %s"), ipList); + return connectionsQty === 1 + ? dhcpAndIps + : // TRANSLATORS: Summary for multiple connections mixing automatic + // (DHCP) and manual configuration. E.g., "Using 2 connections with DHCP + // and 192.168.1.1" + // %d is replaced by the number of connections (e.g., "2") + // %s is replaced by configuration summary (e.g., "DHCP and 192.168.1.1") + // Full example: + sprintf(_("Using %d connections with %s"), connectionsQty, dhcpAndIps); + } + default: + // TRANSLATORS: Generic summary for configured network connections + // %d is replaced by the number of connections + return sprintf( + n_("Configured with %d connection", "Configured with %d connections", connectionsQty), + connectionsQty, + ); + } +}; + +/** + * Helper component that renders a title representing the current network + * configuration based on the provided network status + */ +const Title = ({ status }: TitleProps) => { + const result = { + // TRANSLATORS: Network summary title when no network has been configured + [NetworkStatus.NOT_CONFIGURED]: _("Not configured"), + // TRANSLATORS: Network summary title when connections are set up only for + // installation and won't persist in the installed system + [NetworkStatus.NO_PERSISTENT]: _("Installation only"), + // [NetworkStatus.NO_PERSISTENT]: _("Not persistent"), + // TRANSLATORS: Network summary title when using both automatic (DHCP) and manual configuration + [NetworkStatus.MIXED]: _("Auto and manual"), + // TRANSLATORS: Network summary title when using automatic configuration (DHCP) + [NetworkStatus.AUTO]: _("Auto"), + // TRANSLATORS: Network summary title when using manual/static IP configuration + [NetworkStatus.MANUAL]: _("Manual"), + }; + return result[status]; +}; + +/** + * Renders a summary of the network settings, providing a brief overview of the + * network status and configuration. + * + * It displays the current network "mode" (e.g., Auto, Manual, etc.) and the + * most relevant data (e.g., IP addresses), giving users a quick understanding + * of the network setup before they dive into the section for more details. + */ +export default function NetworkDetailsItem({ withoutLink = false }: { withoutLink?: boolean }) { + const { status, persistentConnections } = useNetworkStatus(); + + return ( + + ) : ( + + + </Link> + ) + } + description={<Description status={status} connections={persistentConnections} />} + /> + ); +} diff --git a/web/src/components/overview/InstallationSummarySection.tsx b/web/src/components/overview/InstallationSummarySection.tsx new file mode 100644 index 0000000000..20f807143d --- /dev/null +++ b/web/src/components/overview/InstallationSummarySection.tsx @@ -0,0 +1,48 @@ +/* + * 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 from "react"; +import Page from "~/components/core/Page"; +import NestedContent from "~/components/core/NestedContent"; +import Details from "~/components/core/Details"; +import HostnameDetailsItem from "~/components/system/HostnameDetailsItem"; +import L10nDetailsItem from "~/components/l10n/L10nDetailsItem"; +import StorageDetailsItem from "../storage/StorageDetailsItem"; +import NetworkDetailsItem from "../network/NetworkDetailsItem"; +import SoftwareDetailsItem from "../software/SoftwareDetailsItem"; +import { _ } from "~/i18n"; + +export default function InstallationSummarySection() { + return ( + <Page.Section title={_("Installation summary")}> + <NestedContent margin="mMd"> + <Details isHorizontal isCompact> + <HostnameDetailsItem /> + <L10nDetailsItem /> + <NetworkDetailsItem /> + <StorageDetailsItem /> + <SoftwareDetailsItem /> + </Details> + </NestedContent> + </Page.Section> + ); +} diff --git a/web/src/components/overview/L10nSection.test.tsx b/web/src/components/overview/L10nSection.test.tsx deleted file mode 100644 index b25460f8c7..0000000000 --- a/web/src/components/overview/L10nSection.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (c) [2023-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 from "react"; -import { screen } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import { L10nSection } from "~/components/overview"; -import { L10n } from "~/model/system"; - -const locales: L10n.Locale[] = [ - { id: "en_US.UTF-8", language: "English", territory: "United States" }, - { id: "de_DE.UTF-8", language: "German", territory: "Germany" }, -]; - -jest.mock("~/queries/system", () => ({ - ...jest.requireActual("~/queries/system"), - useSystem: () => ({ - l10n: { locale: "en_US.UTF-8", locales, keymap: "us" }, - }), -})); - -jest.mock("~/queries/proposal", () => ({ - ...jest.requireActual("~/queries/proposal"), - useProposal: () => ({ - l10n: { locale: "en_US.UTF-8", keymap: "us" }, - }), -})); - -it("displays the selected locale", () => { - plainRender(<L10nSection />); - - expect(screen.getByText(/English \(United States\)/)).toBeInTheDocument(); -}); diff --git a/web/src/components/overview/L10nSection.tsx b/web/src/components/overview/L10nSection.tsx deleted file mode 100644 index 8d692a7571..0000000000 --- a/web/src/components/overview/L10nSection.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) [2023-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 from "react"; -import { Content } from "@patternfly/react-core"; -import { useProposal } from "~/hooks/model/proposal/l10n"; -import { useSystem } from "~/hooks/model/system/l10n"; -import { _ } from "~/i18n"; -import type { Locale } from "~/model/system/l10n"; - -export default function L10nSection() { - const proposal = useProposal(); - const system = useSystem(); - const locale = proposal?.locale && system?.locales?.find((l: Locale) => l.id === proposal.locale); - - // TRANSLATORS: %s will be replaced by a language name and territory, example: - // "English (United States)". - const [msg1, msg2] = _("The system will use %s as its default language.").split("%s"); - - return ( - <Content> - <Content component="h3">{_("Localization")}</Content> - <Content> - <b>{locale ? `${msg1}${locale.id} (${locale.territory})${msg2}` : _("Not selected yet")}</b> - </Content> - </Content> - ); -} diff --git a/web/src/components/overview/OverviewPage.test.tsx b/web/src/components/overview/OverviewPage.test.tsx index d37d118670..36c583fee6 100644 --- a/web/src/components/overview/OverviewPage.test.tsx +++ b/web/src/components/overview/OverviewPage.test.tsx @@ -23,17 +23,10 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { OverviewPage } from "~/components/overview"; - -jest.mock("~/components/overview/L10nSection", () => () => <div>Localization Section</div>); -jest.mock("~/components/overview/StorageSection", () => () => <div>Storage Section</div>); -jest.mock("~/components/overview/SoftwareSection", () => () => <div>Software Section</div>); -jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( - <div>ProductRegistrationAlert</div> -)); +import OverviewPage from "~/components/overview/OverviewPage"; describe("when a product is selected", () => { - it("renders the overview page content", async () => { + it.skip("renders the overview page content", async () => { installerRender(<OverviewPage />); await screen.findByText("Localization Section"); await screen.findByText("Storage Section"); diff --git a/web/src/components/overview/OverviewPage.tsx b/web/src/components/overview/OverviewPage.tsx index 3ebae8a41f..4f734f57af 100644 --- a/web/src/components/overview/OverviewPage.tsx +++ b/web/src/components/overview/OverviewPage.tsx @@ -21,15 +21,14 @@ */ import React from "react"; -import { Content, Grid, GridItem, Stack } from "@patternfly/react-core"; +import { Navigate } from "react-router"; +import { Content, Grid, GridItem } from "@patternfly/react-core"; import { Page } from "~/components/core"; -import L10nSection from "./L10nSection"; -import StorageSection from "./StorageSection"; -import SoftwareSection from "./SoftwareSection"; -import { _ } from "~/i18n"; -import { PRODUCT } from "~/routes/paths"; import { useProduct } from "~/hooks/model/config"; -import { Navigate } from "react-router"; +import { PRODUCT } from "~/routes/paths"; +import { _ } from "~/i18n"; +import SystemInformationSection from "./SystemInformationSection"; +import InstallationSummarySection from "./InstallationSummarySection"; export default function OverviewPage() { const product = useProduct(); @@ -46,17 +45,11 @@ export default function OverviewPage() { <Page.Content> <Grid hasGutter> - <GridItem sm={12}> - <Stack hasGutter> - <Content> - {_( - "These are the most relevant installation settings. Feel free to browse the sections in the menu for further details.", - )} - </Content> - <L10nSection /> - <StorageSection /> - <SoftwareSection /> - </Stack> + <GridItem sm={6}> + <SystemInformationSection /> + </GridItem> + <GridItem sm={6}> + <InstallationSummarySection /> </GridItem> </Grid> </Page.Content> diff --git a/web/src/components/overview/SoftwareSection.test.tsx b/web/src/components/overview/SoftwareSection.test.tsx deleted file mode 100644 index e4e1aba3c5..0000000000 --- a/web/src/components/overview/SoftwareSection.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) [2024] 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 } from "~/test-utils"; -// import testingProposal from "~/components/software/proposal.test.json"; -import SoftwareSection from "~/components/overview/SoftwareSection"; -// import { SoftwareProposal } from "~/types/software"; - -// let mockTestingProposal: SoftwareProposal; - -// FIXME: redo this tests once new overview is done after api v2 -describe.skip("SoftwareSection", () => { - describe("when the proposal does not have patterns to select", () => { - // beforeEach(() => { - // mockTestingProposal = { patterns: {}, size: "" }; - // }); - - it("renders nothing", () => { - const { container } = installerRender(<SoftwareSection />); - expect(container).toBeEmptyDOMElement(); - }); - }); - - describe("when the proposal has patterns to select", () => { - // beforeEach(() => { - // mockTestingProposal = testingProposal; - // }); - - it("renders the required space and the selected patterns", () => { - installerRender(<SoftwareSection />); - screen.getByText("4.6 GiB"); - screen.getAllByText(/GNOME/); - screen.getByText("YaST Base Utilities"); - screen.getByText("YaST Desktop Utilities"); - screen.getByText("Multimedia"); - screen.getAllByText(/Office Software/); - expect(screen.queryByText("KDE")).toBeNull(); - expect(screen.queryByText("XFCE")).toBeNull(); - expect(screen.queryByText("YaST Server Utilities")).toBeNull(); - }); - }); -}); diff --git a/web/src/components/overview/SoftwareSection.tsx b/web/src/components/overview/SoftwareSection.tsx deleted file mode 100644 index e4950398b5..0000000000 --- a/web/src/components/overview/SoftwareSection.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (c) [2022-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 from "react"; -import { Content, List, ListItem } from "@patternfly/react-core"; -import { isEmpty } from "radashi"; -import { _ } from "~/i18n"; -import { useProposal } from "~/hooks/model/proposal/software"; -import { useSystem } from "~/hooks/model/system/software"; -import xbytes from "xbytes"; - -export default function SoftwareSection(): React.ReactNode { - const system = useSystem(); - const proposal = useProposal(); - - if (!proposal) { - return null; - } - - const usedSpace = xbytes(proposal.usedSpace * 1024); - - if (isEmpty(proposal.patterns)) return; - const selectedPatternsIds = Object.keys(proposal.patterns); - - const TextWithoutList = () => { - return ( - <> - {_("The installation will take")} <b>{usedSpace}</b> - </> - ); - }; - - const TextWithList = () => { - // TRANSLATORS: %s will be replaced with the installation size, example: "5GiB". - const [msg1, msg2] = _("The installation will take %s including:").split("%s"); - const selectedPatterns = system.patterns.filter((p) => selectedPatternsIds.includes(p.name)); - - return ( - <> - <Content> - {msg1} - <b>{usedSpace}</b> - {msg2} - </Content> - <List> - {selectedPatterns.map((p) => ( - <ListItem key={p.name}>{p.summary}</ListItem> - ))} - </List> - </> - ); - }; - - return ( - <Content> - <Content component="h3">{_("Software")}</Content> - {selectedPatternsIds.length ? <TextWithList /> : <TextWithoutList />} - </Content> - ); -} diff --git a/web/src/components/overview/StorageSection.test.tsx b/web/src/components/overview/StorageSection.test.tsx deleted file mode 100644 index 581d1d26b3..0000000000 --- a/web/src/components/overview/StorageSection.test.tsx +++ /dev/null @@ -1,278 +0,0 @@ -/* - * Copyright (c) [2022-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 from "react"; -import { screen } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import { StorageSection } from "~/components/overview"; - -let mockModel = { - drives: [], -}; - -const sda = { - sid: 59, - name: "/dev/sda", - description: "", - isDrive: false, - type: "drive", - active: true, - encrypted: false, - shrinking: { unsuppored: [] }, - size: 536870912000, - start: 0, - systems: [], - udevIds: [], - udevPaths: [], -}; - -const sdb = { - sid: 60, - name: "/dev/sdb", - description: "", - isDrive: false, - type: "drive", - active: true, - encrypted: false, - shrinking: { unsuppored: [] }, - size: 697932185600, - start: 0, - systems: [], - udevIds: [], - udevPaths: [], -}; - -jest.mock("~/queries/storage/config-model", () => ({ - ...jest.requireActual("~/queries/storage/config-model"), - useConfigModel: () => mockModel, -})); - -const mockDevices = [sda, sdb]; - -jest.mock("~/queries/storage", () => ({ - ...jest.requireActual("~/queries/storage"), - useDevices: () => mockDevices, -})); - -let mockAvailableDevices = [sda, sdb]; - -jest.mock("~/hooks/storage/system", () => ({ - ...jest.requireActual("~/hooks/storage/system"), - useAvailableDevices: () => mockAvailableDevices, -})); - -let mockSystemErrors = []; - -jest.mock("~/queries/issues", () => ({ - ...jest.requireActual("~/queries/issues"), - useSystemErrors: () => mockSystemErrors, -})); - -beforeEach(() => { - mockSystemErrors = []; -}); - -describe("when the configuration does not include any device", () => { - beforeEach(() => { - mockModel = { - drives: [], - }; - }); - - it("indicates that a device is not selected", async () => { - plainRender(<StorageSection />); - - await screen.findByText(/No device selected/); - }); -}); - -describe("when the configuration contains one drive", () => { - beforeEach(() => { - mockModel = { - drives: [{ name: "/dev/sda", spacePolicy: "delete" }], - }; - }); - - it("renders the proposal summary", async () => { - plainRender(<StorageSection />); - - await screen.findByText(/Install using device/); - await screen.findByText(/sda \(500 GiB\)/); - await screen.findByText(/and deleting all its content/); - }); - - describe("and the space policy is set to 'resize'", () => { - beforeEach(() => { - mockModel = { - drives: [{ name: "/dev/sda", spacePolicy: "resize" }], - }; - }); - - it("indicates that partitions may be shrunk", async () => { - plainRender(<StorageSection />); - - await screen.findByText(/shrinking existing partitions as needed/); - }); - }); - - describe("and the space policy is set to 'keep'", () => { - beforeEach(() => { - mockModel = { - drives: [{ name: "/dev/sda", spacePolicy: "keep" }], - }; - }); - - it("indicates that partitions will be kept", async () => { - plainRender(<StorageSection />); - - await screen.findByText(/without modifying existing partitions/); - }); - }); - - describe("and the space policy is set to 'custom'", () => { - beforeEach(() => { - mockModel = { - drives: [{ name: "/dev/sda", spacePolicy: "custom" }], - }; - }); - - it("indicates that custom strategy for allocating space is set", async () => { - plainRender(<StorageSection />); - - await screen.findByText(/custom strategy to find the needed space/); - }); - }); - - describe("and the drive matches no disk", () => { - beforeEach(() => { - mockModel = { - drives: [{ name: undefined, spacePolicy: "delete" }], - }; - }); - - it("indicates that a device is not selected", async () => { - plainRender(<StorageSection />); - - await screen.findByText(/No device selected/); - }); - }); -}); - -describe("when the configuration contains several drives", () => { - beforeEach(() => { - mockModel = { - drives: [ - { name: "/dev/sda", spacePolicy: "delete" }, - { name: "/dev/sdb", spacePolicy: "delete" }, - ], - }; - }); - - it("renders the proposal summary", async () => { - plainRender(<StorageSection />); - - await screen.findByText(/Install using several devices/); - await screen.findByText(/and deleting all its content/); - }); - - describe("but one of them has a different space policy", () => { - beforeEach(() => { - mockModel = { - drives: [ - { name: "/dev/sda", spacePolicy: "delete" }, - { name: "/dev/sdb", spacePolicy: "resize" }, - ], - }; - }); - - it("indicates that custom strategy for allocating space is set", async () => { - plainRender(<StorageSection />); - - await screen.findByText(/custom strategy to find the needed space/); - }); - }); -}); - -describe("when there is no configuration model (unsupported features)", () => { - beforeEach(() => { - mockModel = null; - }); - - describe("if the storage proposal succeeded", () => { - describe("and there are no available devices", () => { - beforeEach(() => { - mockAvailableDevices = []; - }); - - it("indicates that an unhandled configuration was used", async () => { - plainRender(<StorageSection />); - await screen.findByText(/advanced configuration/); - }); - }); - - describe("and there are available disks", () => { - beforeEach(() => { - mockAvailableDevices = [sda]; - }); - - it("indicates that an unhandled configuration was used", async () => { - plainRender(<StorageSection />); - await screen.findByText(/advanced configuration/); - }); - }); - }); - - describe("if the storage proposal was not possible", () => { - beforeEach(() => { - mockSystemErrors = [ - { - description: "System error", - kind: "storage", - details: "", - scope: "storage", - }, - ]; - }); - - describe("and there are no available devices", () => { - beforeEach(() => { - mockAvailableDevices = []; - }); - - it("indicates that there are no available disks", async () => { - plainRender(<StorageSection />); - await screen.findByText(/no disks available/); - }); - }); - - describe("and there are available devices", () => { - beforeEach(() => { - mockAvailableDevices = [sda]; - }); - - it("indicates that an unhandled configuration was used", async () => { - plainRender(<StorageSection />); - await screen.findByText(/advanced configuration/); - }); - }); - }); -}); diff --git a/web/src/components/overview/StorageSection.tsx b/web/src/components/overview/StorageSection.tsx deleted file mode 100644 index 207a127b58..0000000000 --- a/web/src/components/overview/StorageSection.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright (c) [2022-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 from "react"; -import { Content } from "@patternfly/react-core"; -import { deviceLabel } from "~/components/storage/utils"; -import { useAvailableDevices, useDevices, useIssues } from "~/hooks/model/system/storage"; -import { useConfigModel } from "~/hooks/model/storage/config-model"; -import { _ } from "~/i18n"; -import type { Storage } from "~/model/system"; -import type { ConfigModel } from "~/model/storage/config-model"; - -const findDriveDevice = (drive: ConfigModel.Drive, devices: Storage.Device[]) => - devices.find((d) => d.name === drive.name); - -const NoDeviceSummary = () => _("No device selected yet"); - -const SingleDiskSummary = ({ drive }: { drive: ConfigModel.Drive }) => { - const devices = useDevices(); - const device = findDriveDevice(drive, devices); - const options = { - // TRANSLATORS: %s will be replaced by the device name and its size, - // example: "/dev/sda, 20 GiB" - resize: _("Install using device %s shrinking existing partitions as needed."), - // TRANSLATORS: %s will be replaced by the device name and its size, - // example: "/dev/sda, 20 GiB" - keep: _("Install using device %s without modifying existing partitions."), - // TRANSLATORS: %s will be replaced by the device name and its size, - // example: "/dev/sda, 20 GiB" - delete: _("Install using device %s and deleting all its content."), - // TRANSLATORS: %s will be replaced by the device name and its size, - // example: "/dev/sda, 20 GiB" - custom: _("Install using device %s with a custom strategy to find the needed space."), - }; - - const [textStart, textEnd] = options[drive.spacePolicy].split("%s"); - - return ( - <> - <span>{textStart}</span> - <b>{device ? deviceLabel(device) : drive.name}</b> - <span>{textEnd}</span> - </> - ); -}; - -const MultipleDisksSummary = ({ drives }: { drives: ConfigModel.Drive[] }): string => { - const options = { - resize: _("Install using several devices shrinking existing partitions as needed."), - keep: _("Install using several devices without modifying existing partitions."), - delete: _("Install using several devices and deleting all its content."), - custom: _("Install using several devices with a custom strategy to find the needed space."), - }; - - if (drives.find((d) => d.spacePolicy !== drives[0].spacePolicy)) { - return options.custom; - } - - return options[drives[0].spacePolicy]; -}; - -const ModelSummary = ({ model }: { model: ConfigModel.Config }): React.ReactNode => { - const devices = useDevices(); - const drives = model?.drives || []; - const existDevice = (name: string) => devices.some((d) => d.name === name); - const noDrive = drives.length === 0 || drives.some((d) => !existDevice(d.name)); - - if (noDrive) return <NoDeviceSummary />; - if (drives.length > 1) return <MultipleDisksSummary drives={drives} />; - return <SingleDiskSummary drive={drives[0]} />; -}; - -const NoModelSummary = (): React.ReactNode => { - const availableDevices = useAvailableDevices(); - const systemErrors = useIssues(); - const hasDisks = !!availableDevices.length; - const hasResult = !systemErrors.length; - - if (!hasResult && !hasDisks) return _("There are no disks available for the installation."); - return _("Install using an advanced configuration."); -}; - -/** - * Text explaining the storage proposal - */ -export default function StorageSection() { - const config = useConfigModel(); - - return ( - <Content> - <Content component="h3">{_("Storage")}</Content> - <Content> - {config && <ModelSummary model={config} />} - {!config && <NoModelSummary />} - </Content> - </Content> - ); -} diff --git a/web/src/components/overview/SystemInformationSection.tsx b/web/src/components/overview/SystemInformationSection.tsx new file mode 100644 index 0000000000..6a1008284e --- /dev/null +++ b/web/src/components/overview/SystemInformationSection.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import xbytes from "xbytes"; +import Page from "~/components/core/Page"; +import NestedContent from "~/components/core/NestedContent"; +import Details from "~/components/core/Details"; +import FormattedIPsList from "~/components/network/FormattedIpsList"; +import { useSystem } from "~/hooks/model/system"; +import { _ } from "~/i18n"; + +export default function SystemInformationSection() { + const { hardware } = useSystem(); + + return ( + <Page.Section title={_("System information")}> + <NestedContent margin="mMd"> + <Details isHorizontal isCompact> + <Details.Item label={_("Model")}>{hardware.model}</Details.Item> + <Details.Item label={_("CPU")}>{hardware.cpu}</Details.Item> + <Details.Item label={_("Memory")}> + {hardware.memory ? xbytes(hardware.memory, { iec: true }) : undefined} + </Details.Item> + <Details.Item label={_("IPs")}> + <FormattedIPsList /> + </Details.Item> + </Details> + </NestedContent> + </Page.Section> + ); +} diff --git a/web/src/components/overview/index.ts b/web/src/components/overview/index.ts deleted file mode 100644 index 6db707d5a2..0000000000 --- a/web/src/components/overview/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) [2022-2023] 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. - */ - -export { default as OverviewPage } from "./OverviewPage"; -export { default as L10nSection } from "./L10nSection"; -export { default as StorageSection } from "./StorageSection"; -export { default as SoftwareSection } from "./SoftwareSection"; diff --git a/web/src/components/software/SoftwareDetailsItem.test.tsx b/web/src/components/software/SoftwareDetailsItem.test.tsx new file mode 100644 index 0000000000..ced60d9cb5 --- /dev/null +++ b/web/src/components/software/SoftwareDetailsItem.test.tsx @@ -0,0 +1,162 @@ +/* + * 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 version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 } from "~/test-utils"; +import { useSelectedPatterns } from "~/hooks/model/system/software"; +import { useProgressTracking } from "~/hooks/use-progress-tracking"; +import { useProposal } from "~/hooks/model/proposal/software"; +import { SelectedBy } from "~/model/proposal/software"; +import SoftwareDetailsItem from "./SoftwareDetailsItem"; + +let mockUseProgressTrackingFn: jest.Mock<ReturnType<typeof useProgressTracking>> = jest.fn(); +let mockUseProposalFn: jest.Mock<ReturnType<typeof useProposal>> = jest.fn(); +let mockUseSelectedPatternsFn: jest.Mock<ReturnType<typeof useSelectedPatterns>> = jest.fn(); + +jest.mock("~/hooks/model/system/software", () => ({ + ...jest.requireActual("~/hooks/model/system/software"), + useSelectedPatterns: () => mockUseSelectedPatternsFn(), +})); + +jest.mock("~/hooks/model/proposal/software", () => ({ + ...jest.requireActual("~/hooks/model/proposal/software"), + useProposal: () => mockUseProposalFn(), +})); + +jest.mock("~/hooks/use-progress-tracking", () => ({ + ...jest.requireActual("~/hooks/use-progress-tracking"), + useProgressTracking: () => mockUseProgressTrackingFn(), +})); + +describe("SoftwareDetailsItem", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("when software data is still loading", () => { + beforeEach(() => { + mockUseProgressTrackingFn.mockReturnValue({ + loading: true, + progress: { + index: 1, + scope: "software", + size: 3, + steps: ["one", "two", "three"], + step: "two", + }, + }); + }); + + it("renders skeletons instead of content", () => { + mockUseProposalFn.mockReturnValue({ usedSpace: 0, patterns: {} }); + mockUseSelectedPatternsFn.mockReturnValue([]); + + installerRender(<SoftwareDetailsItem />); + + screen.queryByText(/Software/); + screen.queryByText("Waiting for proposal"); // Skeleton aria-label + expect(screen.queryByText(/Needs about/)).not.toBeInTheDocument(); + }); + }); + + describe("when software data is loaded (no progress active)", () => { + beforeEach(() => { + mockUseProgressTrackingFn.mockReturnValue({ + loading: false, + progress: undefined, + }); + }); + + it("renders 'Required packages' without patterns count when no none is selected", () => { + mockUseProposalFn.mockReturnValue({ usedSpace: 1955420, patterns: {} }); + mockUseSelectedPatternsFn.mockReturnValue([]); + + installerRender(<SoftwareDetailsItem />); + + screen.getByText("Required packages"); + screen.getByText(/Needs about 1\.86 GiB/); + }); + + it("renders 'Required packages' and the patterns count with correct pluralization when some is selected", () => { + mockUseProposalFn.mockReturnValue({ + usedSpace: 6239191, + patterns: { + yast2_server: SelectedBy.NONE, + basic_desktop: SelectedBy.NONE, + xfce: SelectedBy.NONE, + gnome: SelectedBy.USER, + yast2_desktop: SelectedBy.NONE, + kde: SelectedBy.NONE, + multimedia: SelectedBy.NONE, + office: SelectedBy.NONE, + yast2_basis: SelectedBy.AUTO, + selinux: SelectedBy.NONE, + apparmor: SelectedBy.NONE, + }, + }); + + mockUseSelectedPatternsFn.mockReturnValue([ + { + name: "gnome", + category: "Graphical Environments", + icon: "./pattern-gnome-wayland", + description: "The GNOME desktop environment ...", + summary: "GNOME Desktop Environment (Wayland)", + order: 1010, + preselected: false, + }, + ]); + + const { rerender } = installerRender(<SoftwareDetailsItem />); + + // Singular + screen.getByText("Required packages and 1 pattern"); + screen.getByText(/Needs about 5\.95 GiB/); + + mockUseSelectedPatternsFn.mockReturnValue([ + { + name: "gnome", + category: "Graphical Environments", + icon: "./pattern-gnome-wayland", + description: "The GNOME desktop environment ...", + summary: "GNOME Desktop Environment (Wayland)", + order: 1010, + preselected: false, + }, + { + name: "yast2_basis", + category: "Base Technologies", + icon: "./yast", + description: "YaST tools for basic system administration.", + summary: "YaST Base Utilities", + order: 1220, + preselected: false, + }, + ]); + rerender(<SoftwareDetailsItem />); + + // Plural + screen.getByText("Required packages and 2 patterns"); + screen.getByText(/Needs about 5\.95 GiB/); + }); + }); +}); diff --git a/web/src/components/software/SoftwareDetailsItem.tsx b/web/src/components/software/SoftwareDetailsItem.tsx new file mode 100644 index 0000000000..d38e39f208 --- /dev/null +++ b/web/src/components/software/SoftwareDetailsItem.tsx @@ -0,0 +1,90 @@ +/* + * 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 from "react"; +import xbytes from "xbytes"; +import { sprintf } from "sprintf-js"; + +import { useProposal } from "~/hooks/model/proposal/software"; +import { useProgressTracking } from "~/hooks/use-progress-tracking"; +import { useSelectedPatterns } from "~/hooks/model/system/software"; +import { SOFTWARE } from "~/routes/paths"; +import { _, n_ } from "~/i18n"; +import Details from "~/components/core/Details"; +import Link from "~/components/core/Link"; + +/** + * Renders a summary text describing the software selection. + */ +const Summary = () => { + const patterns = useSelectedPatterns(); + const patternsQty = patterns.length; + + if (patternsQty === 0) { + return _("Required packages"); + } + + return sprintf( + // TRANSLATORS: %s will be replaced with amount of selected patterns. + n_("Required packages and %s pattern", "Required packages and %s patterns", patternsQty), + patternsQty, + ); +}; + +/** + * Renders the estimated disk space required for the installation. + */ +const Description = () => { + const proposal = useProposal(); + + if (!proposal.usedSpace) return; + + return sprintf( + // TRANSLATORS: %s will be replaced with a human-readable installation size + // (e.g. 5.95 GiB). + _("Needs about %s"), + xbytes(proposal.usedSpace * 1024, { iec: true }), + ); +}; + +/** + * A software installation summary. + */ +export default function SoftwareDetailsItem({ withoutLink = false }: { withoutLink?: boolean }) { + const { loading } = useProgressTracking("software"); + return ( + <Details.StackItem + label={_("Software")} + content={ + withoutLink ? ( + <Summary /> + ) : ( + <Link to={SOFTWARE.root} variant="link" isInline> + <Summary /> + </Link> + ) + } + description={<Description />} + isLoading={loading} + /> + ); +} diff --git a/web/src/components/storage/PotentialDataLossAlert.tsx b/web/src/components/storage/PotentialDataLossAlert.tsx new file mode 100644 index 0000000000..134dc9fd0f --- /dev/null +++ b/web/src/components/storage/PotentialDataLossAlert.tsx @@ -0,0 +1,62 @@ +/* + * 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 { Alert, ExpandableSection, List, ListItem } from "@patternfly/react-core"; +import { _, formatList } from "~/i18n"; +import { sprintf } from "sprintf-js"; +import { useDestructiveActions } from "~/hooks/use-destructive-actions"; + +// FIXME: this component has a bunch of logic/calls copied from +// storage/ProposalResultSection that should be moved to a reusable hook. +export default function PotentialDataLossAlert() { + let title: string; + const { actions, affectedSystems } = useDestructiveActions(); + + if (actions.length === 0) return; + + if (affectedSystems.length) { + title = sprintf( + // TRANSLATORS: %s will be replaced by a formatted list of affected + // systems like "Windows and openSUSE Tumbleweed". + _("Proceeding may result in data loss affecting at least %s"), + formatList(affectedSystems), + ); + } else { + title = _("Proceeding may result in data loss"); + } + + return ( + <Alert title={title} variant="danger"> + <ExpandableSection + toggleTextCollapsed={_("View details")} + toggleTextExpanded={_("Hide details")} + > + <List> + {actions.map((a, i) => ( + <ListItem key={i}>{a.text}</ListItem> + ))} + </List> + </ExpandableSection> + </Alert> + ); +} diff --git a/web/src/components/storage/StorageDetailsItem.tsx b/web/src/components/storage/StorageDetailsItem.tsx new file mode 100644 index 0000000000..8e1d2ffa8c --- /dev/null +++ b/web/src/components/storage/StorageDetailsItem.tsx @@ -0,0 +1,156 @@ +/* + * 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 from "react"; +import { sprintf } from "sprintf-js"; + +import Details from "~/components/core/Details"; +import Link from "~/components/core/Link"; + +import { useConfigModel } from "~/hooks/model/storage/config-model"; +import { + useFlattenDevices as useSystemFlattenDevices, + useAvailableDevices, + useDevices, +} from "~/hooks/model/system/storage"; +import { + useFlattenDevices as useProposalFlattenDevices, + useActions, +} from "~/hooks/model/proposal/storage"; +import DevicesManager from "~/model/storage/devices-manager"; +import { useIssues } from "~/hooks/model/issue"; +import { deviceLabel } from "~/components/storage/utils"; +import { STORAGE } from "~/routes/paths"; +import { _, formatList } from "~/i18n"; + +import type { Storage } from "~/model/system"; +import type { ConfigModel } from "~/model/storage/config-model"; +import { useProgressTracking } from "~/hooks/use-progress-tracking"; + +const findDriveDevice = (drive: ConfigModel.Drive, devices: Storage.Device[]) => + devices.find((d) => d.name === drive.name); + +const NoDeviceSummary = () => _("No device selected yet"); + +const SingleDeviceSummary = ({ target }: { target: ConfigModel.Drive | ConfigModel.MdRaid }) => { + const devices = useDevices(); + const device = findDriveDevice(target, devices); + // TRANSLATORS: %s will be replaced by the device name and its size, + // example: "/dev/sda, 20 GiB" + const text = _("Use device %s"); + const [textStart, textEnd] = text.split("%s"); + + return ( + <div> + <span>{textStart}</span> + <b>{device ? deviceLabel(device) : target.name}</b> + <span>{textEnd}</span> + </div> + ); +}; + +const ModelSummary = ({ model }: { model: ConfigModel.Config }): React.ReactNode => { + const devices = useDevices(); + const drives = model?.drives || []; + // We are only interested in RAIDs and VGs that are being reused. With the current model, + // that means all RAIDs and no VGs. Revisit when (a) the model allows to create new RAIDs or + // (b) the model allows to reuse existing VGs. + const raids = model?.mdRaids || []; + const targets = drives.concat(raids); + const existDevice = (name: string) => devices.some((d) => d.name === name); + const noTarget = targets.length === 0 || targets.some((d) => !existDevice(d.name)); + + if (noTarget) return <NoDeviceSummary />; + if (targets.length > 1) return _("Use several devices"); + return <SingleDeviceSummary target={targets[0]} />; +}; + +const LinkContent = () => { + const availableDevices = useAvailableDevices(); + const model = useConfigModel(); + const issues = useIssues("storage"); + const configIssues = issues.filter((i) => i.class !== "proposal"); + + if (!availableDevices.length) return _("There are no disks available for the installation"); + if (configIssues.length) return _("Invalid settings"); + if (!model) return _("Using an advanced storage configuration"); + + return <ModelSummary model={model} />; +}; + +const DescriptionContent = () => { + const system = useSystemFlattenDevices(); + const staging = useProposalFlattenDevices(); + const actions = useActions(); + const issues = useIssues("storage"); + const configIssues = issues.filter((i) => i.class !== "proposal"); + const manager = new DevicesManager(system, staging, actions); + + if (configIssues.length) return; + if (!actions.length) return _("Failed to calculate a storage layout"); + + const deleteActions = manager.actions.filter((a) => a.delete && !a.subvol).length; + if (!deleteActions) return _("No data loss is expected"); + + const systems = manager.deletedSystems(); + if (systems.length) { + return sprintf( + // TRANSLATORS: %s will be replaced by a formatted list of affected systems + // like "Windows and openSUSE Tumbleweed". + _("Potential data loss affecting at least %s"), + formatList(systems), + ); + } + + return _("Potential data loss"); +}; + +/** + * In the near future, this component may receive one or more props (to be + * defined) to display additional or alternative information. This will be + * especially useful for reusing the component in the interface where users are + * asked to confirm that they want to proceed with the installation. + * + * DISCLAIMER: Naming still has significant room for improvement, starting with + * the component name itself. These changes should be addressed in a final step, + * once all "overview/confirmation" items are clearly defined. + */ +export default function StorageDetailsItem({ withoutLink = false }: { withoutLink?: boolean }) { + const { loading } = useProgressTracking("storage"); + + return ( + <Details.StackItem + label={_("Storage")} + content={ + withoutLink ? ( + <LinkContent /> + ) : ( + <Link to={STORAGE.root} variant="link" isInline> + <LinkContent /> + </Link> + ) + } + description={<DescriptionContent />} + isLoading={loading} + /> + ); +} diff --git a/web/src/components/system/HostnameDetailsItem.tsx b/web/src/components/system/HostnameDetailsItem.tsx new file mode 100644 index 0000000000..a5e7acdbeb --- /dev/null +++ b/web/src/components/system/HostnameDetailsItem.tsx @@ -0,0 +1,60 @@ +/* + * 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 from "react"; +import { isEmpty } from "radashi"; +import Details from "~/components/core/Details"; +import Link from "~/components/core/Link"; +import { useProposal } from "~/hooks/model/proposal/hostname"; +import { HOSTNAME } from "~/routes/paths"; +import { _ } from "~/i18n"; + +/** + * Hostname settings summary + * + * If a transient hostname is in use, it shows a brief explanation to inform + * users that the hostname may change after reboot or network changes. + */ +export default function HostnameDetailsItem({ withoutLink = false }: { withoutLink?: boolean }) { + const { hostname: transientHostname, static: staticHostname } = useProposal(); + + return ( + <Details.StackItem + label={_("Hostname")} + content={ + withoutLink ? ( + staticHostname || transientHostname + ) : ( + <Link to={HOSTNAME.root} variant="link" isInline> + {staticHostname || transientHostname} + </Link> + ) + } + description={ + // TRANSLATORS: a note to briefly explain the possible side-effects + // of using a transient hostname + isEmpty(staticHostname) && + _("Temporary name that may change after reboot or network changes") + } + /> + ); +} diff --git a/web/src/hooks/model/system/network.test.ts b/web/src/hooks/model/system/network.test.ts new file mode 100644 index 0000000000..8a9ea33260 --- /dev/null +++ b/web/src/hooks/model/system/network.test.ts @@ -0,0 +1,309 @@ +/* + * 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 { NetworkStatus, getIpAddresses, getNetworkStatus } from "~/hooks/model/system/network"; +import { + Connection, + ConnectionMethod, + ConnectionState, + ConnectionType, + Device, + DeviceState, +} from "~/model/network/types"; + +const createConnection = ( + id: string, + overrides: Partial<ConstructorParameters<typeof Connection>[1]> = {}, +): Connection => { + return new Connection(id, { + method4: ConnectionMethod.AUTO, + method6: ConnectionMethod.AUTO, + state: ConnectionState.activated, + persistent: true, + ...overrides, + }); +}; + +const createDevice = (overrides: Partial<Device> = {}): Device => ({ + name: "eth0", + type: ConnectionType.ETHERNET, + state: DeviceState.CONNECTED, + addresses: [{ address: "192.168.1.100", prefix: 24 }], + nameservers: [], + gateway4: "192.168.1.1", + gateway6: "", + method4: ConnectionMethod.AUTO, + method6: ConnectionMethod.AUTO, + macAddress: "AA:11:22:33:44:55", + ...overrides, +}); + +describe("getNetworkStatus", () => { + it("returns NOT_CONFIGURED status when no connections are given", () => { + const result = getNetworkStatus([]); + + expect(result.status).toBe(NetworkStatus.NOT_CONFIGURED); + expect(result.connections).toEqual([]); + expect(result.persistentConnections).toEqual([]); + }); + + it("returns all given connections", () => { + const persistentConnection = createConnection("Network 1"); + const nonPersistentConnection = createConnection("Network 2", { + method4: ConnectionMethod.MANUAL, + method6: ConnectionMethod.MANUAL, + state: ConnectionState.activating, + persistent: false, + }); + + const result = getNetworkStatus([persistentConnection, nonPersistentConnection]); + + expect(result.connections).toEqual([persistentConnection, nonPersistentConnection]); + }); + + it("returns given persistent connections", () => { + const persistentConnection = createConnection("Network 1"); + const nonPersistentConnection = createConnection("Network 2", { + method4: ConnectionMethod.MANUAL, + method6: ConnectionMethod.MANUAL, + state: ConnectionState.activating, + persistent: false, + }); + + const result = getNetworkStatus([persistentConnection, nonPersistentConnection]); + + expect(result.persistentConnections).toEqual([persistentConnection]); + }); + + it("returns NO_PERSISTENT status when there are no persistent connections and includeNonPersistent is false", () => { + const nonPersistentConnection = createConnection("Network 1", { + method4: ConnectionMethod.MANUAL, + method6: ConnectionMethod.MANUAL, + state: ConnectionState.activating, + persistent: false, + }); + + const result = getNetworkStatus([nonPersistentConnection], { includeNonPersistent: false }); + + expect(result.status).toBe(NetworkStatus.NO_PERSISTENT); + }); + + it("checks against non-persistent connections too when includeNonPersistent is true", () => { + const persistentConnection = createConnection("Network 1"); + const nonPersistentConnection = createConnection("Network 2", { + method4: ConnectionMethod.MANUAL, + method6: ConnectionMethod.MANUAL, + state: ConnectionState.activating, + persistent: false, + }); + + const result = getNetworkStatus([persistentConnection, nonPersistentConnection], { + includeNonPersistent: true, + }); + + expect(result.status).toBe(NetworkStatus.MIXED); + }); + + it("returns AUTO status when there are only connections with auto method and without static IP addresses", () => { + const autoConnection = createConnection("Network 1", { + state: ConnectionState.activating, + addresses: [], + }); + + const result = getNetworkStatus([autoConnection]); + + expect(result.status).toBe(NetworkStatus.AUTO); + }); + + it("returns MANUAL status when there are only manual connections", () => { + const manualConnection = createConnection("Network 1", { + method4: ConnectionMethod.MANUAL, + method6: ConnectionMethod.MANUAL, + state: ConnectionState.activating, + }); + + const result = getNetworkStatus([manualConnection]); + + expect(result.status).toBe(NetworkStatus.MANUAL); + }); + + it("returns MANUAL status when connection has manual method and static IP addresses", () => { + const manualWithStaticIp = createConnection("Network 1", { + method4: ConnectionMethod.MANUAL, + method6: ConnectionMethod.MANUAL, + state: ConnectionState.activating, + addresses: [{ address: "192.168.1.10", prefix: 24 }], + }); + + const result = getNetworkStatus([manualWithStaticIp]); + + expect(result.status).toBe(NetworkStatus.MANUAL); + }); + + it("returns MIXED status when there are both manual and auto connections", () => { + const mixedConnection = createConnection("Network 1", { + method4: ConnectionMethod.AUTO, + method6: ConnectionMethod.MANUAL, + state: ConnectionState.activating, + }); + + const result = getNetworkStatus([mixedConnection]); + + expect(result.status).toBe(NetworkStatus.MIXED); + }); + + it("returns MIXED status when there is an auto connection with static IP address", () => { + const autoWithStaticIp = createConnection("Network 1", { + state: ConnectionState.activating, + addresses: [{ address: "192.168.1.10", prefix: 24 }], + }); + + const result = getNetworkStatus([autoWithStaticIp]); + + expect(result.status).toBe(NetworkStatus.MIXED); + }); +}); + +describe("getIpAddresses", () => { + it("returns empty array when no devices are given", () => { + const connection = createConnection("conn1"); + const result = getIpAddresses([], [connection]); + + expect(result).toEqual([]); + }); + + it("returns empty array when no connections are given", () => { + const device = createDevice({ + addresses: [ + { address: "192.168.1.100", prefix: 24 }, + { address: "192.168.1.101", prefix: 24 }, + ], + }); + + const result = getIpAddresses([device], []); + + expect(result).toEqual([]); + }); + + it("returns IP addresses from devices linked to existing connections", () => { + const connection = createConnection("conn1"); + const device = createDevice({ + connection: connection.id, + addresses: [ + { address: "192.168.1.100", prefix: 24 }, + { address: "fe80::1", prefix: 64 }, + ], + nameservers: ["8.8.8.8"], + }); + + const result = getIpAddresses([device], [connection]); + + expect(result).toEqual([ + { address: "192.168.1.100", prefix: 24 }, + { address: "fe80::1", prefix: 64 }, + ]); + }); + + it("filters out devices not linked to any connection", () => { + const connection = createConnection("conn1"); + const linkedDevice = createDevice({ connection: connection.id }); + const unlinkedDevice = createDevice({ + name: "wlan0", + type: ConnectionType.WIFI, + state: DeviceState.DISCONNECTED, + addresses: [{ address: "192.168.1.200", prefix: 24 }], + gateway4: "", + macAddress: "BB:11:22:33:44:55", + }); + + const result = getIpAddresses([linkedDevice, unlinkedDevice], [connection]); + + expect(result).toEqual([{ address: "192.168.1.100", prefix: 24 }]); + }); + + it("flattens IP addresses from multiple devices", () => { + const connection1 = createConnection("conn1"); + const connection2 = createConnection("conn2"); + + const device1 = createDevice({ + connection: connection1.id, + addresses: [ + { address: "192.168.1.100", prefix: 24 }, + { address: "192.168.1.101", prefix: 24 }, + ], + }); + + const device2 = createDevice({ + name: "wlan0", + connection: connection2.id, + type: ConnectionType.WIFI, + addresses: [{ address: "10.0.0.50", prefix: 8 }], + gateway4: "10.0.0.1", + macAddress: "BB:11:22:33:44:55", + }); + + const result = getIpAddresses([device1, device2], [connection1, connection2]); + + expect(result).toEqual([ + { address: "192.168.1.100", prefix: 24 }, + { address: "192.168.1.101", prefix: 24 }, + { address: "10.0.0.50", prefix: 8 }, + ]); + }); + + it("returns formatted IP addresses when formatted option is true", () => { + const connection = createConnection("conn1"); + const device = createDevice({ + connection: connection.id, + addresses: [ + { address: "192.168.1.100", prefix: 24 }, + { address: "fe80::1", prefix: 64 }, + ], + }); + + const result = getIpAddresses([device], [connection], { formatted: true }); + + expect(result).toEqual(["192.168.1.100", "fe80::1"]); + }); + + it("returns raw IPAddress objects when formatted option is false", () => { + const connection = createConnection("conn1"); + const device = createDevice({ connection: connection.id }); + + const result = getIpAddresses([device], [connection], { formatted: false }); + + expect(result).toEqual([{ address: "192.168.1.100", prefix: 24 }]); + }); + + it("returns empty array when devices have no addresses", () => { + const connection = createConnection("conn1"); + const device = createDevice({ + connection: connection.id, + addresses: [], + gateway4: "", + }); + + const result = getIpAddresses([device], [connection]); + + expect(result).toEqual([]); + }); +}); diff --git a/web/src/hooks/model/system/network.ts b/web/src/hooks/model/system/network.ts index 59f1ab6d87..d12bfe31a8 100644 --- a/web/src/hooks/model/system/network.ts +++ b/web/src/hooks/model/system/network.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -33,9 +33,13 @@ import { DeviceState, WifiNetwork, ConnectionState, + IPAddress, + ConnectionMethod, } from "~/types/network"; import { useInstallerClient } from "~/context/installer"; import React, { useCallback } from "react"; +import { formatIp } from "~/utils/network"; +import { isEmpty } from "radashi"; const selectSystem = (data: System | null): NetworkSystem => data ? NetworkSystem.fromApi(data.network) : null; @@ -115,6 +119,181 @@ const useWifiNetworks = () => { }); }; +/** + * Options to influence returned value by useIpAddresses + */ +type UseIpAddressesOptions = { + /** + * Whether format IPs as string or not. When true, returns IP addresses as + * formatted strings without prefix. If false or omitted, returns raw + * IPAddress objects. + */ + formatted?: boolean; +}; + +/** + * @internal + * + * Retrieves all IP addresses from devices associated with active connections. + * + * It filters devices to only include those linked to existing connections, then + * extracts and flattens all IP addresses from those devices. + * + * @note + * + * This is actually the implementation of {@link useIpAddresses} + * + * Extracted and exported for testing purposes only. See reasoning at note in + * {@link useNetworkStatus} + * + */ +function getIpAddresses( + devices: Device[] = [], + connections: Connection[] = [], + options: UseIpAddressesOptions = {}, +) { + const connectionsIds = connections.map((c) => c.id); + const filteredDevices = devices.filter((d) => connectionsIds.includes(d.connection)); + + if (options.formatted) { + return filteredDevices.flatMap((d) => + d.addresses.map((a) => formatIp(a, { removePrefix: true })), + ); + } + + return filteredDevices.flatMap((d) => d.addresses); +} + +/** + * Retrieves all IP addresses from devices associated with active connections. + * + * @see {@link getIpAddresses} for implementation details + */ +function useIpAddresses(options: { formatted: true }): string[]; +function useIpAddresses(options?: { formatted?: false }): IPAddress[]; +function useIpAddresses(options: UseIpAddressesOptions = {}): string[] | IPAddress[] { + const devices = useDevices(); + const connections = useConnections(); + + return getIpAddresses(devices, connections, options); +} + +/** + * Network status constants + */ +export const NetworkStatus = { + MANUAL: "manual", + AUTO: "auto", + MIXED: "mixed", + NOT_CONFIGURED: "not_configured", + NO_PERSISTENT: "no_persistent", +}; + +type NetworkStatusType = (typeof NetworkStatus)[keyof typeof NetworkStatus]; + +/** + * Options for useNetworkStatus hook + */ +export type NetworkStatusOptions = { + /** If true, uses also non-persistent connections to determine the network + * configuration status */ + includeNonPersistent?: boolean; +}; + +/** + * @internal + * + * Determines the global network configuration status. + * + * @note + * + * This is actually the implementation of {@link useNetworkStatus} + * + * @note + * + * Exported for testing purposes only. Since useNetworkStatus and useConnections + * live in the same module, mocking useConnections doesn't work because internal + * calls within a module bypass mocks. + * + * Rather than split hooks into multiple files or mock React Query internals + * (both worse trade-offs), the main logic was extracted as a pure function for + * direct testing. Given the complexity and importance of this network status + * logic, leaving it untested was not an acceptable option. + * + * If a better approach is found, this can be moved back into + * the hook. + */ +function getNetworkStatus( + connections: Connection[], + { includeNonPersistent = false }: NetworkStatusOptions = {}, +) { + const persistentConnections = connections.filter((c) => c.persistent); + + // Filter connections based on includeNonPersistent option + const connectionsToCheck = includeNonPersistent ? connections : persistentConnections; + + let status: NetworkStatusType; + + if (isEmpty(connections)) { + status = NetworkStatus.NOT_CONFIGURED; + } else if (!includeNonPersistent && isEmpty(connectionsToCheck)) { + status = NetworkStatus.NO_PERSISTENT; + } else { + const someManual = connectionsToCheck.some( + (c) => + c.method4 === ConnectionMethod.MANUAL || + c.method6 === ConnectionMethod.MANUAL || + !isEmpty(c.addresses), + ); + + const someAuto = connectionsToCheck.some( + (c) => c.method4 === ConnectionMethod.AUTO || c.method6 === ConnectionMethod.AUTO, + ); + + if (someManual && someAuto) { + status = NetworkStatus.MIXED; + } else if (someAuto) { + status = NetworkStatus.AUTO; + } else { + status = NetworkStatus.MANUAL; + } + } + + return { + status, + connections, + persistentConnections, + }; +} + +/** + * Determines the global network configuration status. + * + * Returns the network status, the full collection of connections (both + * persistent and non-persistent), and a filtered list of only persistent + * connections. + * + * The `status` reflects the network configuration, depending on the state of + * connections (whether there are connections, and whether some are persistent) + * and the so called network mode (manual, auto, mixed) + * - If there are no connections, the status will be `NetworkStatus.NOT_CONFIGURED`. + * - If there are no persistent connections and `includeNonPersistent` is false + * (the default), the status will be `NetworkStatus.NO_PERSISTENT`. + * - When `includeNonPersistent` is true, non-persistent connections are + * included in the mode calculation, and the **NO_PERSISTENT** status is ignored. + * - When at least one connection has defined at least one static IP, the + * status will be either, manual or mixed depending in the connection.method4 + * and connection.method6 value + * + * + * @see {@link getNetworkStatus} for implementation details and why the logic + * was extracted. + */ +const useNetworkStatus = ({ includeNonPersistent = false }: NetworkStatusOptions = {}) => { + const connections = useConnections(); + return getNetworkStatus(connections, { includeNonPersistent }); +}; + /** * FIXME: ADAPT to the new config HTTP API * Hook that returns a useEffect to listen for NetworkChanged events @@ -192,4 +371,15 @@ const useNetworkChanges = () => { }, [client, queryClient, updateDevices, updateConnectionState]); }; -export { useConnections, useDevices, useNetworkChanges, useSystem, useWifiNetworks, useState }; +export { + useConnections, + useDevices, + useNetworkChanges, + useNetworkStatus, + useSystem, + useIpAddresses, + useWifiNetworks, + useState, + getNetworkStatus, + getIpAddresses, +}; diff --git a/web/src/hooks/model/system/software.ts b/web/src/hooks/model/system/software.ts index c043396689..f619d1ef42 100644 --- a/web/src/hooks/model/system/software.ts +++ b/web/src/hooks/model/system/software.ts @@ -20,8 +20,11 @@ * find current contact information at www.suse.com. */ +import { shake } from "radashi"; import { useSuspenseQuery } from "@tanstack/react-query"; import { systemQuery } from "~/hooks/model/system"; +import { useProposal } from "~/hooks/model/proposal/software"; +import { SelectedBy } from "~/model/proposal/software"; import type { System, Software } from "~/model/system"; const selectSystem = (data: System | null): Software.System => data?.software; @@ -34,4 +37,18 @@ function useSystem(): Software.System | null { return data; } -export { useSystem }; +/** + * Retrieves the list of patterns currently selected in the active proposal. + */ +function useSelectedPatterns() { + const proposal = useProposal(); + const { patterns } = useSystem(); + + const selectedPatternsKeys = Object.keys( + shake(proposal.patterns, (value) => value === SelectedBy.NONE), + ); + + return patterns.filter((p) => selectedPatternsKeys.includes(p.name)); +} + +export { useSystem, useSelectedPatterns }; diff --git a/web/src/hooks/use-destructive-actions.ts b/web/src/hooks/use-destructive-actions.ts new file mode 100644 index 0000000000..18e8bd6c86 --- /dev/null +++ b/web/src/hooks/use-destructive-actions.ts @@ -0,0 +1,44 @@ +/* + * 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 DevicesManager from "~/model/storage/devices-manager"; +import { useFlattenDevices as useSystemFlattenDevices } from "~/hooks/model/system/storage"; +import { + useFlattenDevices as useProposalFlattenDevices, + useActions, +} from "~/hooks/model/proposal/storage"; + +/** + * Custom hook that returns a list of destructive actions and affected systemsm + * + * FIXME:: review, document, test and relocate if needed. + */ +export function useDestructiveActions() { + const system = useSystemFlattenDevices(); + const staging = useProposalFlattenDevices(); + const actions = useActions(); + const manager = new DevicesManager(system, staging, actions); + const affectedSystems = manager.deletedSystems(); + const deleteActions = manager.actions.filter((a) => a.delete && !a.subvol); + + return { actions: deleteActions, affectedSystems }; +} diff --git a/web/src/hooks/use-progress-tracking.test.ts b/web/src/hooks/use-progress-tracking.test.ts new file mode 100644 index 0000000000..98632343b5 --- /dev/null +++ b/web/src/hooks/use-progress-tracking.test.ts @@ -0,0 +1,145 @@ +/* + * 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 { renderHook, waitFor } from "@testing-library/react"; +import { useProgressTracking } from "./use-progress-tracking"; +import { useStatus } from "~/hooks/model/status"; +import useTrackQueriesRefetch from "~/hooks/use-track-queries-refetch"; +import { COMMON_PROPOSAL_KEYS } from "~/hooks/model/proposal"; +import type { Progress } from "~/model/status"; +import { act } from "react"; + +const mockProgressesFn: jest.Mock<Progress[]> = jest.fn(); + +jest.mock("~/hooks/use-track-queries-refetch"); + +jest.mock("~/hooks/model/status", () => ({ + ...jest.requireActual("~/hooks/model/status"), + useStatus: (): ReturnType<typeof useStatus> => ({ + stage: "configuring", + progresses: mockProgressesFn(), + }), +})); + +const fakeProgress: Progress = { + index: 1, + scope: "software", + size: 3, + steps: ["one", "two", "three"], + step: "two", +}; + +describe("useProgressTracking", () => { + let mockStartTracking: jest.Mock; + let mockRefetchCallback: (startedAt: number, completedAt: number) => void; + + beforeEach(() => { + jest.useFakeTimers(); + mockStartTracking = jest.fn(); + + // Capture the callback passed to useTrackQueriesRefetch + (useTrackQueriesRefetch as jest.Mock).mockImplementation((_, callback) => { + mockRefetchCallback = callback; + return { startTracking: mockStartTracking }; + }); + + mockProgressesFn.mockReturnValue([]); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + it("uses COMMON_PROPOSAL_KEYS by default", () => { + renderHook(() => useProgressTracking("software")); + + expect(useTrackQueriesRefetch).toHaveBeenCalledWith(COMMON_PROPOSAL_KEYS, expect.any(Function)); + }); + + it("returns loading false when there is no active progress", () => { + const { result } = renderHook(() => useProgressTracking("software")); + + expect(result.current.loading).toBe(false); + expect(result.current.progress).toBeUndefined(); + }); + + it("returns loading true when progress starts", () => { + mockProgressesFn.mockReturnValue([fakeProgress]); + const { result } = renderHook(() => useProgressTracking("software")); + + expect(result.current.loading).toBe(true); + expect(result.current.progress).toBe(fakeProgress); + }); + + it("keeps loading true until all queries refetch after progress completes", async () => { + const { result, rerender } = renderHook(() => useProgressTracking("software")); + + // Start progress + mockProgressesFn.mockReturnValue([fakeProgress]); + rerender(); + + // Complete progress + jest.setSystemTime(1000); + mockProgressesFn.mockReturnValue([]); + rerender(); + + await waitFor(() => { + expect(mockStartTracking).toHaveBeenCalledTimes(1); + }); + + expect(result.current.loading).toBe(true); + + // Queries refetch after progress finished + jest.setSystemTime(2000); + + act(() => { + mockRefetchCallback(1000, 2000); + }); + + expect(result.current.loading).toBe(false); + }); + + it("ignores query refetches completed before progress finished", async () => { + const { result, rerender } = renderHook(() => useProgressTracking("software")); + + // Start progress + mockProgressesFn.mockReturnValue([fakeProgress]); + rerender(); + + // Complete progress + jest.setSystemTime(2000); + mockProgressesFn.mockReturnValue([]); + rerender(); + + await waitFor(() => { + expect(mockStartTracking).toHaveBeenCalled(); + }); + + // Queries refetched before progress finished, must be ignored + act(() => { + mockRefetchCallback(500, 1000); + }); + + expect(result.current.loading).toBe(true); + }); +}); diff --git a/web/src/hooks/use-progress-tracking.ts b/web/src/hooks/use-progress-tracking.ts new file mode 100644 index 0000000000..469da8057f --- /dev/null +++ b/web/src/hooks/use-progress-tracking.ts @@ -0,0 +1,124 @@ +/* + * 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 { useEffect, useRef, useState } from "react"; +import useTrackQueriesRefetch from "~/hooks/use-track-queries-refetch"; +import { useStatus } from "~/hooks/model/status"; +import { COMMON_PROPOSAL_KEYS } from "./model/proposal"; +import type { Scope } from "~/model/status"; + +// FIXME: copied from StorageDetailsItem, +// FIXME: evaluate to extract this hook and use it instead of repeating such a +// #find in multiple places +const useProgress = (scope: Scope) => { + const { progresses } = useStatus(); + return progresses.find((p) => p.scope === scope); +}; + +/** + * Custom hook that manages loading state for operations with progress tracking. + * + * This hook coordinates between progress status from the backend and query + * refetch completion to provide a seamless loading experience. It ensures the + * UI remains in a loading state until both the backend operation completes AND + * all related queries have been refetched with fresh data. + * + * @param scope - The progress scope to monitor (e.g., "software", "storage") + * @param queryKeys - Array of TanStack Query keys to track for refetches after + * progress completes. Defaults to COMMON_PROPOSAL_KEYS if not provided. + * + * @returns Object containing: + * - `loading`: Boolean indicating whether an operation is in progress or + * waiting for queries to refetch + * - `progress`: The current progress object from the backend, or undefined + * if no matching progress is active + * + * @example + * ```tsx + * // Basic usage with default query keys + * function SoftwareSummary() { + * const { loading } = useProgressTracking("software"); + * + * if (loading) return <Skeleton />; + * return <SoftwareSummary />; + * } + * ``` + * + * @example + * ```tsx + * // With custom query keys to ensure specific data is refetched + * function ProgressBackdrop({ scope, ensureRefetched }) { + * const { loading: isBlocked, progress } = useProgressTracking( + * scope, + * [...COMMON_PROPOSAL_KEYS, ...ensureRefetched] + * ); + * + * if (!isBlocked) return null; + * return <Backdrop message={progress.message} />; + * } + * ``` + * + * @remarks + * + * In short, the hook works as follow + * + * 1. Backend operation starts → `loading` becomes `true` + * 2. Backend operation finishes → hook waits for queries to refetch + * 3. useTrackQueriesRefetch reports all queries refetched with fresh data → + * `loading` becomes `false` + * + * The hook uses a ref to track when the operation finished, ensuring queries + * are only considered "fresh" if they refetched AFTER the operation completed + * to prevents showing stale data to users. + * + * @see {@link useProgress} - For monitoring backend progress status + * @see {@link useTrackQueriesRefetch} - For tracking query refetch completion + */ +export function useProgressTracking( + scope: Scope, + queryKeys: readonly string[] = COMMON_PROPOSAL_KEYS, +) { + const progress = useProgress(scope); + const [loading, setLoading] = useState(false); + const progressFinishedAtRef = useRef(null); + + const { startTracking } = useTrackQueriesRefetch(queryKeys, (_, completedAt) => { + if (progressFinishedAtRef.current && completedAt > progressFinishedAtRef.current) { + setLoading(false); + progressFinishedAtRef.current = null; + } + }); + + useEffect(() => { + if (!progress && loading && !progressFinishedAtRef.current) { + progressFinishedAtRef.current = Date.now(); + startTracking(); + } + }, [progress, startTracking, loading, progressFinishedAtRef]); + + if (progress && !loading) { + setLoading(true); + progressFinishedAtRef.current = null; + } + + return { loading, progress }; +} diff --git a/web/src/hooks/use-track-queries-refetch.ts b/web/src/hooks/use-track-queries-refetch.ts index 535ac62914..ac07dbc8f9 100644 --- a/web/src/hooks/use-track-queries-refetch.ts +++ b/web/src/hooks/use-track-queries-refetch.ts @@ -69,7 +69,7 @@ import { useQueryClient } from "@tanstack/react-query"; * @see {@link https://tanstack.com/query/latest/docs/react/reference/QueryObserver} */ function useTrackQueriesRefetch( - queryKeys: string[], + queryKeys: readonly string[], onSuccess: (startedAt: number, completedAt: number) => void, ) { const queryClient = useQueryClient(); diff --git a/web/src/router.tsx b/web/src/router.tsx index 81a9e73ce1..298532a41d 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -32,7 +32,9 @@ import { LoginPage, } from "~/components/core"; import StorageProgress from "~/components/storage/Progress"; -import { OverviewPage } from "~/components/overview"; +import OverviewPage from "~/components/overview/OverviewPage"; +import HostnamePage from "~/components/system/HostnamePage"; +import ConfirmPage from "~/components/core/ConfirmPage"; import l10nRoutes from "~/routes/l10n"; import networkRoutes from "~/routes/network"; import productsRoutes from "~/routes/products"; @@ -42,7 +44,6 @@ import softwareRoutes from "~/routes/software"; import usersRoutes from "~/routes/users"; import { HOSTNAME, ROOT as PATHS, STORAGE } from "./routes/paths"; import { N_ } from "~/i18n"; -import HostnamePage from "./components/system/HostnamePage"; const rootRoutes = () => [ { @@ -82,6 +83,14 @@ const protectedRoutes = () => [ element: <PlainLayout />, children: [productsRoutes()], }, + { + path: PATHS.confirm, + element: ( + <PlainLayout headerOptions={{ showProductName: false, showInstallerOptions: false }}> + <ConfirmPage /> + </PlainLayout> + ), + }, ], }, { diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index 96f764f142..002528a183 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -49,6 +49,7 @@ const ROOT = { root: "/", login: "/login", overview: "/overview", + confirm: "/confirm", installation: "/installation", installationProgress: "/installation/progress", installationFinished: "/installation/finished", @@ -115,6 +116,7 @@ const HOSTNAME = { */ const SIDE_PATHS = [ ROOT.login, + ROOT.confirm, PRODUCT.changeProduct, PRODUCT.progress, ROOT.installationProgress, diff --git a/web/src/test-utils.tsx b/web/src/test-utils.tsx index 79605afcb5..8a116ebcaf 100644 --- a/web/src/test-utils.tsx +++ b/web/src/test-utils.tsx @@ -32,7 +32,7 @@ import React from "react"; import { MemoryRouter, useParams } from "react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; -import { render, within } from "@testing-library/react"; +import { render, renderHook, within } from "@testing-library/react"; import { createClient } from "~/client/index"; import { InstallerClientProvider } from "~/context/installer"; import { InstallerL10nProvider } from "~/context/installerL10n"; @@ -211,6 +211,20 @@ const installerRender = (ui: React.ReactNode, options: { withL10n?: boolean } = }; }; +/** + * Wrapper around react-testing-library#renderHook for testing custom Tanstack Query based hooks + */ +const installerRenderHook: typeof renderHook = (hook, options) => { + const queryClient = new QueryClient({}); + + return renderHook(hook, { + ...options, + wrapper: ({ children }) => ( + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> + ), + }); +}; + /** * Wrapper around react-testing-library#render for rendering components without * installer providers. @@ -297,6 +311,7 @@ const getColumnValues = (table: HTMLElement | HTMLTableElement, columnName: stri export { plainRender, installerRender, + installerRenderHook, createCallbackMock, mockNavigateFn, mockParams, diff --git a/web/src/utils/network.test.ts b/web/src/utils/network.test.ts index f00e8e415b..992d3f700e 100644 --- a/web/src/utils/network.test.ts +++ b/web/src/utils/network.test.ts @@ -84,6 +84,13 @@ describe("formatIp", () => { expect(formatIp({ address: "1.2.3.4", prefix: 24 })).toEqual("1.2.3.4/24"); expect(formatIp({ address: "1.2.3.4", prefix: "255.255.255.0" })).toEqual("1.2.3.4/24"); }); + + it("returns the given IPv4 address in the X.X.X.X format when removePrefix option is true", () => { + expect(formatIp({ address: "1.2.3.4", prefix: 24 }, { removePrefix: true })).toEqual("1.2.3.4"); + expect( + formatIp({ address: "1.2.3.4", prefix: "255.255.255.0" }, { removePrefix: true }), + ).toEqual("1.2.3.4"); + }); }); describe("securityFromFlags", () => { diff --git a/web/src/utils/network.ts b/web/src/utils/network.ts index 47916b921d..589bce84d4 100644 --- a/web/src/utils/network.ts +++ b/web/src/utils/network.ts @@ -21,6 +21,7 @@ */ import ipaddr from "ipaddr.js"; +import { isUndefined } from "radashi"; import { APIRoute, ApFlags, @@ -121,12 +122,12 @@ const stringToIPInt = (text: string): number => { /** * Returns given IP address in the X.X.X.X/YY format */ -const formatIp = (addr: IPAddress): string => { - if (addr.prefix === undefined) { - return `${addr.address}`; - } else { - return `${addr.address}/${ipPrefixFor(addr.prefix)}`; +const formatIp = (addr: IPAddress, options = { removePrefix: false }): string => { + if (isUndefined(addr.prefix) || options.removePrefix) { + return addr.address; } + + return `${addr.address}/${ipPrefixFor(addr.prefix)}`; }; const buildAddress = (address: string): IPAddress => {