);
@@ -96,14 +99,11 @@ describe("App", () => {
beforeEach(() => {
// setting the language through a cookie
document.cookie = "agamaLang=en-us; path=/;";
- createClient.mockImplementation(() => {
+ (createClient as jest.Mock).mockImplementation(() => {
return {};
});
- mockProducts = [
- { id: "openSUSE", name: "openSUSE Tumbleweed" },
- { id: "Leap Micro", name: "openSUSE Micro" },
- ];
+ mockProducts = [tumbleweed, microos];
});
afterEach(() => {
@@ -142,7 +142,7 @@ describe("App", () => {
describe("if the service is busy", () => {
beforeEach(() => {
mockClientStatus.isBusy = true;
- mockSelectedProduct = { id: "Tumbleweed" };
+ mockSelectedProduct = tumbleweed;
});
it("redirects to product selection progress", async () => {
@@ -163,15 +163,29 @@ describe("App", () => {
});
});
- describe("on the installaiton phase", () => {
+ describe("on the busy installaiton phase", () => {
+ beforeEach(() => {
+ mockClientStatus.phase = InstallationPhase.Install;
+ mockClientStatus.isBusy = true;
+ mockSelectedProduct = tumbleweed;
+ });
+
+ it("navigates to installation progress", async () => {
+ installerRender(, { withL10n: true });
+ await screen.findByText("Navigating to /installation/progress");
+ });
+ });
+
+ describe("on the idle installaiton phase", () => {
beforeEach(() => {
mockClientStatus.phase = InstallationPhase.Install;
- mockSelectedProduct = { id: "Fake product" };
+ mockClientStatus.isBusy = false;
+ mockSelectedProduct = tumbleweed;
});
- it("renders the application content", async () => {
+ it("navigates to installation finished", async () => {
installerRender(, { withL10n: true });
- await screen.findByText("Installation Mock");
+ await screen.findByText("Navigating to /installation/finished");
});
});
});
diff --git a/web/src/App.jsx b/web/src/App.tsx
similarity index 65%
rename from web/src/App.jsx
rename to web/src/App.tsx
index 444e3f3f28..9fe9761571 100644
--- a/web/src/App.jsx
+++ b/web/src/App.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2022-2023] SUSE LLC
+ * Copyright (c) [2022-2024] SUSE LLC
*
* All Rights Reserved.
*
@@ -22,26 +22,22 @@
import React from "react";
import { Navigate, Outlet, useLocation } from "react-router-dom";
-import { Loading } from "./components/layout";
+import { ServerError } from "~/components/core";
+import { Loading, PlainLayout } from "~/components/layout";
import { Questions } from "~/components/questions";
-import { ServerError, Installation } from "~/components/core";
-import { useInstallerL10n } from "./context/installerL10n";
+import { useInstallerL10n } from "~/context/installerL10n";
import { useInstallerClientStatus } from "~/context/installer";
-import { useProduct, useProductChanges } from "./queries/software";
+import { useProduct, useProductChanges } from "~/queries/software";
import { useL10nConfigChanges } from "~/queries/l10n";
-import { useIssuesChanges } from "./queries/issues";
-import { useInstallerStatus, useInstallerStatusChanges } from "./queries/status";
-import { useDeprecatedChanges } from "./queries/storage";
-import { PATHS as PRODUCT_PATHS } from "./routes/products";
-import SimpleLayout from "./SimpleLayout";
-import { InstallationPhase } from "./types/status";
+import { useIssuesChanges } from "~/queries/issues";
+import { useInstallerStatus, useInstallerStatusChanges } from "~/queries/status";
+import { useDeprecatedChanges } from "~/queries/storage";
+import { PATHS as PRODUCT_PATHS } from "~/routes/products";
+import { PATHS as ROOT_PATHS } from "~/router";
+import { InstallationPhase } from "~/types/status";
/**
* Main application component.
- *
- * @param {object} props
- * @param {number} [props.max_attempts=3] - Connection attempts before displaying an
- * error (3 by default). The component will keep trying to connect.
*/
function App() {
const location = useLocation();
@@ -56,25 +52,33 @@ function App() {
useDeprecatedChanges();
const Content = () => {
- if (error) return ;
+ if (error)
+ return (
+
+
+
+ );
+
+ if (phase === InstallationPhase.Install && isBusy) {
+ return ;
+ }
- if (phase === InstallationPhase.Install) {
- return ;
+ if (phase === InstallationPhase.Install && !isBusy) {
+ return ;
}
- if (!products || !connected)
+ if (!products || !connected) return ;
+
+ if (phase === InstallationPhase.Startup && isBusy) {
return (
-
+
-
+
);
-
- if (phase === InstallationPhase.Startup && isBusy) {
- return ;
}
if (selectedProduct === undefined && location.pathname !== PRODUCT_PATHS.root) {
- return ;
+ return ;
}
if (
diff --git a/web/src/SimpleLayout.jsx b/web/src/SimpleLayout.jsx
deleted file mode 100644
index c1f9c8f344..0000000000
--- a/web/src/SimpleLayout.jsx
+++ /dev/null
@@ -1,62 +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, { Suspense } from "react";
-import { Outlet } from "react-router-dom";
-import {
- Masthead,
- MastheadContent,
- Page,
- Toolbar,
- ToolbarContent,
- ToolbarGroup,
- ToolbarItem,
-} from "@patternfly/react-core";
-import { InstallerOptions } from "./components/core";
-import { Loading } from "./components/layout";
-
-/**
- * Simple layout for displaying content that comes before product configuration
- * TODO: improve documentation
- */
-export default function SimpleLayout({
- showOutlet = true,
- showInstallerOptions = false,
- children,
-}) {
- return (
-
-
-
-
-
-
- {showInstallerOptions && }
-
-
-
-
-
- }>{showOutlet ? : children}
-
- );
-}
diff --git a/web/src/assets/styles/patternfly-overrides.scss b/web/src/assets/styles/patternfly-overrides.scss
index 4fbdc7b943..2c6ab76be7 100644
--- a/web/src/assets/styles/patternfly-overrides.scss
+++ b/web/src/assets/styles/patternfly-overrides.scss
@@ -67,7 +67,28 @@
--pf-v5-c-button--m-secondary--Color: var(--color-link-hover);
}
-.pf-v5-c-modal-box__body {
+// Redefine style for primary buttons placed at top bar
+.pf-v5-c-masthead__content {
+ .pf-v5-c-button.pf-m-primary {
+ --pf-v5-c-button--FontSize: 120%;
+ --pf-v5-c-button--m-primary--BackgroundColor: var(--color-button-primary-hover);
+ letter-spacing: 1px;
+ }
+
+ .pf-v5-c-button.pf-m-primary:hover {
+ --pf-v5-c-button--m-primary--BackgroundColor: #1ea064; // var(--color-button-primary);
+ }
+
+ .pf-v5-c-button.pf-m-primary:focus:hover {
+ --pf-v5-c-button--m-primary--BackgroundColor: #1ea064; // var(--color-button-primary);
+ }
+
+ .pf-v5-c-button.pf-m-warning {
+ color: var(--color-button-primary);
+ }
+}
+
+.pf-v5-c-button.pf-m-primary .pf-v5-c-modal-box__body {
padding-block: var(--pf-v5-c-modal-box__body--PaddingTop);
}
@@ -272,7 +293,7 @@
// Allows the pf-m-current directly in the a element instead of li.
// Needed because setting the pf-m-current in ReactRouter/NavLink (the one
-// that knowst that link "isActive")
+// that know the link "isActive")
.pf-v5-c-tabs__link.pf-m-current {
--pf-v5-c-tabs__link--after--BorderColor: var(
diff --git a/web/src/components/core/About.test.tsx b/web/src/components/core/About.test.tsx
deleted file mode 100644
index cbb2f1d568..0000000000
--- a/web/src/components/core/About.test.tsx
+++ /dev/null
@@ -1,77 +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.
- */
-
-import React from "react";
-import { screen, waitFor, within } from "@testing-library/react";
-import { plainRender } from "~/test-utils";
-
-import About from "./About";
-
-describe("About", () => {
- it("renders a help icon inside the button by default", () => {
- const { container } = plainRender();
- const icon = container.querySelector("svg");
- expect(icon).toHaveAttribute("data-icon-name", "help");
- });
-
- it("does not render a help icon inside the button if showIcon=false", () => {
- const { container } = plainRender();
- const icon = container.querySelector("svg");
- expect(icon).toBeNull();
- });
-
- it("allows setting its icon size", () => {
- const { container } = plainRender();
- const icon = container.querySelector("svg");
- expect(icon.classList.contains("icon-xxs")).toBe(true);
- });
-
- it("allows setting its button text", () => {
- plainRender();
- screen.getByRole("button", { name: "What is this?" });
- });
-
- it("allows setting button props", () => {
- plainRender();
- const button = screen.getByRole("button", { name: "About" });
- expect(button.classList.contains("pf-m-link")).toBe(true);
- expect(button.classList.contains("pf-m-inline")).toBe(true);
- });
-
- it("allows user to read 'About Agama'", async () => {
- const { user } = plainRender();
-
- const button = screen.getByRole("button", { name: /About/i });
- await user.click(button);
-
- const dialog = await screen.findByRole("dialog");
-
- within(dialog).getByText("About Agama");
-
- const closeButton = within(dialog).getByRole("button", { name: /Close/i });
- await user.click(closeButton);
-
- await waitFor(() => {
- expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
- });
- });
-});
diff --git a/web/src/components/core/About.tsx b/web/src/components/core/About.tsx
deleted file mode 100644
index 0eacb2ce80..0000000000
--- a/web/src/components/core/About.tsx
+++ /dev/null
@@ -1,94 +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.
- */
-
-import React, { useState } from "react";
-import { Button, ButtonProps, Text } from "@patternfly/react-core";
-import { Icon } from "~/components/layout";
-import { Popup } from "~/components/core";
-import { sprintf } from "sprintf-js";
-import { _ } from "~/i18n";
-
-export type AboutProps = {
- /** Whether render a "help" icon into the button */
- showIcon?: boolean;
- /** The size for the button icon, if any */
- iconSize?: string;
- /** The text for the button */
- buttonText?: string;
- /** Props for the button, see {@link https://www.patternfly.org/components/button PF/Button} */
- buttonProps?: ButtonProps;
-} & ButtonProps;
-
-/**
- * Renders a button and a dialog to allow user read about what Agama is
- */
-export default function About({
- showIcon = true,
- iconSize = "s",
- buttonText = _("About"),
- buttonProps = { variant: "link" },
-}: AboutProps) {
- const [isOpen, setIsOpen] = useState(false);
-
- const open = () => setIsOpen(true);
- const close = () => setIsOpen(false);
-
- return (
- <>
- }
- onClick={open}
- {...buttonProps}
- >
- {buttonText}
-
-
-
-
- {
- // TRANSLATORS: content of the "About" popup (1/2)
- _(
- "Agama is an experimental installer for (open)SUSE systems. It \
-is still under development so, please, do not use it in \
-production environments. If you want to give it a try, we \
-recommend using a virtual machine to prevent any possible \
-data loss.",
- )
- }
-
-
- {sprintf(
- // TRANSLATORS: content of the "About" popup (2/2)
- // %s is replaced by the project URL
- _("For more information, please visit the project's page at %s."),
- "https://agama-project.github.io/",
- )}
-
-
-
- {_("Close")}
-
-
-
- >
- );
-}
diff --git a/web/src/components/core/InstallButton.test.tsx b/web/src/components/core/InstallButton.test.tsx
index b19a50a032..1adb4570c8 100644
--- a/web/src/components/core/InstallButton.test.tsx
+++ b/web/src/components/core/InstallButton.test.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2022-2023] SUSE LLC
+ * Copyright (c) [2022-2024] SUSE LLC
*
* All Rights Reserved.
*
@@ -22,19 +22,59 @@
import React from "react";
import { screen, waitFor } from "@testing-library/react";
-import { plainRender } from "~/test-utils";
+import { installerRender, mockRoutes } from "~/test-utils";
import { InstallButton } from "~/components/core";
+import { IssuesList } from "~/types/issues";
+import { PATHS as PRODUCT_PATHS } from "~/routes/products";
const mockStartInstallationFn = jest.fn();
+let mockIssuesList: IssuesList;
jest.mock("~/api/manager", () => ({
...jest.requireActual("~/api/manager"),
startInstallation: () => mockStartInstallationFn(),
}));
-describe("when the button is clicked and there are not errors", () => {
- it("starts the installation after user confirmation", async () => {
- const { user } = plainRender();
+jest.mock("~/queries/issues", () => ({
+ ...jest.requireActual("~/queries/issues"),
+ useAllIssues: () => mockIssuesList,
+}));
+
+describe("when there are installation issues", () => {
+ beforeEach(() => {
+ mockIssuesList = new IssuesList(
+ [
+ {
+ description: "Fake Issue",
+ source: 0,
+ severity: 0,
+ details: "Fake Issue details",
+ },
+ ],
+ [],
+ [],
+ [],
+ );
+ });
+
+ it("renders nothing", () => {
+ const { container } = installerRender();
+ expect(container).toBeEmptyDOMElement();
+ });
+});
+
+describe("when there are not installation issues", () => {
+ beforeEach(() => {
+ mockIssuesList = new IssuesList([], [], [], []);
+ });
+
+ it("renders an Install button", () => {
+ installerRender();
+ screen.getByRole("button", { name: "Install" });
+ });
+
+ it("starts the installation after user clicks on it and accept the confirmation", async () => {
+ const { user } = installerRender();
const button = await screen.findByRole("button", { name: "Install" });
await user.click(button);
@@ -43,8 +83,8 @@ describe("when the button is clicked and there are not errors", () => {
expect(mockStartInstallationFn).toHaveBeenCalled();
});
- it("does not start the installation if the user cancels", async () => {
- const { user } = plainRender();
+ it("does not start the installation if the user clicks on it but cancels the confirmation", async () => {
+ const { user } = installerRender();
const button = await screen.findByRole("button", { name: "Install" });
await user.click(button);
@@ -56,4 +96,26 @@ describe("when the button is clicked and there are not errors", () => {
expect(screen.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument();
});
});
+
+ describe("but installer is rendering the product selection", () => {
+ beforeEach(() => {
+ mockRoutes(PRODUCT_PATHS.changeProduct);
+ });
+
+ it("renders nothing", () => {
+ const { container } = installerRender();
+ expect(container).toBeEmptyDOMElement();
+ });
+ });
+
+ describe("but installer is configuring a product", () => {
+ beforeEach(() => {
+ mockRoutes(PRODUCT_PATHS.progress);
+ });
+
+ it("renders nothing", () => {
+ const { container } = installerRender();
+ expect(container).toBeEmptyDOMElement();
+ });
+ });
});
diff --git a/web/src/components/core/InstallButton.tsx b/web/src/components/core/InstallButton.tsx
index 875ce79f2e..0d7c56512f 100644
--- a/web/src/components/core/InstallButton.tsx
+++ b/web/src/components/core/InstallButton.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2022-2023] SUSE LLC
+ * Copyright (c) [2022-2024] SUSE LLC
*
* All Rights Reserved.
*
@@ -22,11 +22,14 @@
import React, { useState } from "react";
-import { Button, Stack } from "@patternfly/react-core";
+import { Button, ButtonProps, Stack } from "@patternfly/react-core";
import { Popup } from "~/components/core";
import { _ } from "~/i18n";
import { startInstallation } from "~/api/manager";
+import { useAllIssues } from "~/queries/issues";
+import { useLocation } from "react-router-dom";
+import { PATHS as PRODUCT_PATHS } from "~/routes/products";
const InstallConfirmationPopup = ({ onAccept, onClose }) => {
return (
@@ -59,23 +62,23 @@ according to the provided installation settings.",
*
* It starts the installation after asking for confirmation.
*
- * @component
- *
- * @example
- * console.log("clicked!")} />
- *
- * @param {object} props
- * @param {() => void} [props.onClick] - function to call when the user clicks the button
*/
-const InstallButton = () => {
+const InstallButton = (props: Omit) => {
+ const issues = useAllIssues();
const [isOpen, setIsOpen] = useState(false);
+ const location = useLocation();
+
+ if (!issues.isEmpty) return;
+ // Do not show the button if the user is about to change the product or the
+ // installer is configuring a product.
+ if ([PRODUCT_PATHS.changeProduct, PRODUCT_PATHS.progress].includes(location.pathname)) return;
const open = async () => setIsOpen(true);
const close = () => setIsOpen(false);
return (
<>
-