diff --git a/service/lib/dinstaller/dbus/manager.rb b/service/lib/dinstaller/dbus/manager.rb index 8baf3591d9..c5931eb5d9 100644 --- a/service/lib/dinstaller/dbus/manager.rb +++ b/service/lib/dinstaller/dbus/manager.rb @@ -60,6 +60,7 @@ def initialize(backend, logger) dbus_method(:Probe, "") { config_phase } dbus_method(:Commit, "") { install_phase } dbus_method(:CanInstall, "out result:b") { can_install? } + dbus_method(:CollectLogs, "out tarball_filesystem_path:s, in user:s") { |u| collect_logs(u) } dbus_reader :installation_phases, "aa{sv}" dbus_reader :current_installation_phase, "u" dbus_reader :busy_services, "as" @@ -88,6 +89,11 @@ def can_install? backend.valid? end + # Collects the YaST logs + def collect_logs(user) + backend.collect_logs(user) + end + # Description of all possible installation phase values # # @return [Array] diff --git a/service/lib/dinstaller/manager.rb b/service/lib/dinstaller/manager.rb index b9ccb2ea25..b3fe17e20f 100644 --- a/service/lib/dinstaller/manager.rb +++ b/service/lib/dinstaller/manager.rb @@ -180,6 +180,18 @@ def valid? [storage, users, software].all?(&:valid?) end + # Collects the logs and stores them into an archive + # + # @param user [String] local username who will own archive + # @return [String] path to created archive + def collect_logs(user) + output = Yast::Execute.locally!("save_y2logs", stderr: :capture) + path = output[/^.* (\/tmp\/y2log-\S*)/, 1] + Yast::Execute.locally!("chown", "#{user}:", path) + + path + end + private attr_reader :config diff --git a/service/package/rubygem-d-installer.changes b/service/package/rubygem-d-installer.changes index 77c8c9506b..d90482c78d 100644 --- a/service/package/rubygem-d-installer.changes +++ b/service/package/rubygem-d-installer.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Jan 18 08:03:40 UTC 2023 - Josef Reidinger + +- Save logs and provide the path to the file + (gh#yast/d-installer#379) + ------------------------------------------------------------------- Tue Jan 17 10:06:23 UT0 2023 - Josef Reidinger diff --git a/service/test/dinstaller/manager_test.rb b/service/test/dinstaller/manager_test.rb index 218593b80b..cc7a683b86 100644 --- a/service/test/dinstaller/manager_test.rb +++ b/service/test/dinstaller/manager_test.rb @@ -203,4 +203,17 @@ end end end + + describe "#collect_logs" do + it "collects the logs and returns the path to the archive" do + expect(Yast::Execute).to receive(:locally!) + .with("save_y2logs", stderr: :capture) + .and_return("Saving YaST logs to /tmp/y2log-hWBn95.tar.xz") + expect(Yast::Execute).to receive(:locally!) + .with("chown", "ytm:", /y2log-hWBn95/) + + path = subject.collect_logs("ytm") + expect(path).to eq("/tmp/y2log-hWBn95.tar.xz") + end + end end diff --git a/web/package/cockpit-d-installer.changes b/web/package/cockpit-d-installer.changes index 061923027f..51bbd704a4 100644 --- a/web/package/cockpit-d-installer.changes +++ b/web/package/cockpit-d-installer.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Wed Jan 18 08:06:05 UTC 2023 - Josef Reidinger + +- Allow user downloading logs (gh#yast/d-installer#379) + ------------------------------------------------------------------- Thu Jan 12 16:23:54 UTC 2023 - Josef Reidinger diff --git a/web/src/App.jsx b/web/src/App.jsx index 2f5f5a72cd..46d9d96adf 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -26,9 +26,8 @@ import { useInstallerClient } from "@context/installer"; import { STARTUP, INSTALL } from "@client/phase"; import { BUSY } from "@client/status"; -import { Layout, Title, AdditionalInfo, LoadingEnvironment, DBusError } from "@components/layout"; -import { About, InstallationProgress, InstallationFinished } from "@components/core"; -import { TargetIpsPopup } from "@components/network"; +import { Layout, Title, LoadingEnvironment, DBusError } from "@components/layout"; +import { InstallationProgress, InstallationFinished } from "@components/core"; function App() { const client = useInstallerClient(); @@ -79,10 +78,6 @@ function App() { D-Installer - - - - ); } diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx index 6e0e2d9ee5..4ea4e8e016 100644 --- a/web/src/App.test.jsx +++ b/web/src/App.test.jsx @@ -21,7 +21,7 @@ import React from "react"; import { act, screen } from "@testing-library/react"; -import { installerRender, mockComponent } from "@/test-utils"; +import { installerRender, mockComponent, mockLayout } from "@/test-utils"; import App from "./App"; import { createClient } from "@client"; import { STARTUP, CONFIG, INSTALL } from "@client/phase"; @@ -33,6 +33,8 @@ jest.mock('react-router-dom', () => ({ Outlet: mockComponent("Content"), })); +jest.mock("@components/layout/Layout", () => mockLayout()); + // Mock some components, // See https://www.chakshunyu.com/blog/how-to-mock-a-react-component-in-jest/#default-export jest.mock("@components/layout/DBusError", () => mockComponent("D-BusError Mock")); @@ -40,7 +42,6 @@ jest.mock("@components/layout/LoadingEnvironment", () => mockComponent("LoadingE jest.mock("@components/questions/Questions", () => mockComponent("Questions Mock")); jest.mock("@components/core/InstallationProgress", () => mockComponent("InstallationProgress Mock")); jest.mock("@components/core/InstallationFinished", () => mockComponent("InstallationFinished Mock")); -jest.mock("@components/network/TargetIpsPopup", () => mockComponent("Target IPs Mock")); const callbacks = {}; const getStatusFn = jest.fn(); @@ -177,10 +178,5 @@ describe("App", () => { installerRender(); await screen.findByText("Content"); }); - - it("renders IP address and hostname", async () => { - installerRender(); - await screen.findByText("Target IPs Mock"); - }); }); }); diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index 74dc7a069e..258c7e9838 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -52,3 +52,37 @@ section > .content { .selection-list [data-state="unstyled"] { border: 0; } + +.sidebar { + --color-background-primary: var(--color-primary); + --wrapper-background: var(--color-gray-light); + + position: absolute; + padding: 0; + right: 0; + z-index: 1; + inline-size: 70%; + box-shadow: 0px 0px 20px 10px var(--color-primary-darkest); +} + +.sidebar footer { + border-top: 1px solid var(--color-gray); +} + +// Remove not wanted PatternFly padding left on a loading link +.sidebar button.pf-m-progress { + --pf-c-button--m-progress--PaddingLeft: var(--pf-global--spacer--md); +} +.sidebar button.pf-m-progress + div { + padding-inline-start: calc(var(--pf-global--spacer--md)); +} + +.sidebar[data-state="hidden"] { + transition: all 0.04s ease-in-out; + inline-size: 0; + box-shadow: none; +} + +.sidebar[data-state="visible"] { + transition: all 0.2s ease-in-out; +} diff --git a/web/src/assets/styles/composition.scss b/web/src/assets/styles/composition.scss index fb8a3bc8eb..c8d17d0124 100644 --- a/web/src/assets/styles/composition.scss +++ b/web/src/assets/styles/composition.scss @@ -3,6 +3,13 @@ margin-block-start: var(--stack-gutter); } +.flex-stack { + display: flex; + flex-direction: column; + align-items: start; + @extend .stack; +} + .split { display: flex; align-items: center; diff --git a/web/src/assets/styles/layout.scss b/web/src/assets/styles/layout.scss index a2581fc37b..d81f1c714e 100644 --- a/web/src/assets/styles/layout.scss +++ b/web/src/assets/styles/layout.scss @@ -1,6 +1,6 @@ .wrapper { display: grid; - grid-template-rows: auto 1fr auto; + grid-template-rows: var(--header-block-size) 1fr var(--footer-block-size); grid-template-areas: 'header' 'content' @@ -11,7 +11,7 @@ block-size: 100dvb; max-inline-size: 1024px; margin-inline: auto; - background: white; + background: var(--wrapper-background); svg { fill: currentColor; @@ -20,7 +20,7 @@ } .wrapper > * { - padding: var(--spacer-normal); + padding: var(--wrapper-padding); } .wrapper > header { @@ -28,7 +28,7 @@ --color-button-plain-link-hover: #fcfcfc; grid-area: header; - background: var(--color-primary-darkest); + background: var(--color-background-primary); color: var(--color-text-secondary); } @@ -46,3 +46,7 @@ .wrapper > footer > img { max-inline-size: 150px; } + +[data-variant="flip-X"] { + transform: scaleX(-1); +} diff --git a/web/src/assets/styles/variables.scss b/web/src/assets/styles/variables.scss index 0420ec832b..137f302d0b 100644 --- a/web/src/assets/styles/variables.scss +++ b/web/src/assets/styles/variables.scss @@ -24,8 +24,12 @@ --stack-gutter: var(--spacer-normal); --split-gutter: var(--spacer-small); + --wrapper-padding: var(--spacer-normal); + --wrapper-background: white; + --color-primary: #30BA78; --color-primary-darkest: #0C322C; + --color-gray-light: #FCFCFC; --color-gray: #F2F2F2; --color-gray-dark: #EFEFEF; // Fog --color-gray-darker: #999999; @@ -55,4 +59,7 @@ --gradient-border-end-color: transparent; --icon-size-m: 32px; + + --header-block-size: 6vh; + --footer-block-size: 8vh; } diff --git a/web/src/client/manager.js b/web/src/client/manager.js index ea2ed117e1..aaed7abe27 100644 --- a/web/src/client/manager.js +++ b/web/src/client/manager.js @@ -79,6 +79,18 @@ class ManagerBaseClient { return proxy.CanInstall(); } + /** + * Returns the binary content of the YaST logs file + * + * @return {Promise} + */ + async fetchLogs() { + const proxy = await this.client.proxy(MANAGER_IFACE); + const path = proxy.CollectLogs("root"); + const file = cockpit.file(path, { binary: true }); + return file.read(); + } + /** * Return the installer status * diff --git a/web/src/client/manager.test.js b/web/src/client/manager.test.js index 07d7eb5a95..bc7e794846 100644 --- a/web/src/client/manager.test.js +++ b/web/src/client/manager.test.js @@ -37,6 +37,7 @@ const managerProxy = { Commit: jest.fn(), Probe: jest.fn(), CanInstall: jest.fn(), + CollectLogs: jest.fn(), CurrentInstallationPhase: 0 }; @@ -148,3 +149,17 @@ describe("#canInstall", () => { }); }); }); + +describe("#fetchLogs", () => { + beforeEach(() => { + managerProxy.CollectLogs = jest.fn(() => "/tmp/y2log-hWBn95.tar.xz"); + cockpit.file = jest.fn(() => ({ read: () => "fake-binary-data" })); + }); + + it("returns the logs file binary content", async () => { + const client = new ManagerClient(); + const logsContent = await client.fetchLogs(); + expect(logsContent).toEqual("fake-binary-data"); + expect(cockpit.file).toHaveBeenCalledWith("/tmp/y2log-hWBn95.tar.xz", { binary: true }); + }); +}); diff --git a/web/src/components/core/About.jsx b/web/src/components/core/About.jsx index 6dea57e6d9..3f7c5db571 100644 --- a/web/src/components/core/About.jsx +++ b/web/src/components/core/About.jsx @@ -20,18 +20,28 @@ */ import React, { useState } from "react"; +import { noop } from "@/utils"; import { Button, Text } from "@patternfly/react-core"; +import { Icon } from "@components/layout"; import { Popup } from "@components/core"; -export default function About() { +export default function About({ onClickCallback = noop }) { const [isOpen, setIsOpen] = useState(false); - const open = () => setIsOpen(true); + const open = () => { + setIsOpen(true); + onClickCallback(); + }; + const close = () => setIsOpen(false); return ( <> - diff --git a/web/src/components/core/About.test.jsx b/web/src/components/core/About.test.jsx index 1c56e135b1..bbbe8fba8d 100644 --- a/web/src/components/core/About.test.jsx +++ b/web/src/components/core/About.test.jsx @@ -22,13 +22,13 @@ import React from "react"; import { screen, waitFor, within } from "@testing-library/react"; -import { installerRender } from "@/test-utils"; +import { plainRender } from "@/test-utils"; import About from "./About"; describe("About", () => { it("allows user to read 'About D-Installer'", async () => { - const { user } = installerRender(); + const { user } = plainRender(); const button = screen.getByRole("button", { name: /About/i }); await user.click(button); @@ -44,4 +44,14 @@ describe("About", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); }); + + it("triggers given onClickCallback function when opening the dialog", async () => { + const onClickCallback = jest.fn(); + + const { user } = plainRender(); + const button = screen.getByRole("button", { name: /About/i }); + + await user.click(button); + expect(onClickCallback).toHaveBeenCalled(); + }); }); diff --git a/web/src/components/core/ChangeProductButton.jsx b/web/src/components/core/ChangeProductButton.jsx new file mode 100644 index 0000000000..1cb8d90a9a --- /dev/null +++ b/web/src/components/core/ChangeProductButton.jsx @@ -0,0 +1,49 @@ +/* + * Copyright (c) [2022] 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 { Button } from "@patternfly/react-core"; +import { useNavigate } from "react-router-dom"; +import { useSoftware } from "@context/software"; +import { Icon } from "@components/layout"; +import { noop } from "@/utils"; + +export default function ChangeProductButton({ onClickCallback = noop }) { + const { products } = useSoftware(); + const navigate = useNavigate(); + + if (products === undefined || products.length === 1) { + return null; + } + + return ( + + ); +} diff --git a/web/src/components/core/ChangeProductButton.test.jsx b/web/src/components/core/ChangeProductButton.test.jsx new file mode 100644 index 0000000000..6fb4580558 --- /dev/null +++ b/web/src/components/core/ChangeProductButton.test.jsx @@ -0,0 +1,100 @@ +/* + * Copyright (c) [2022] 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, waitFor } from "@testing-library/react"; +import { plainRender } from "@/test-utils"; +import { createClient } from "@client"; +import { ChangeProductButton } from "@components/core"; + +let mockProducts; +const mockNavigateFn = jest.fn(); + +jest.mock("@client"); +jest.mock("@context/software", () => ({ + ...jest.requireActual("@context/software"), + useSoftware: () => { + return { + products: mockProducts, + }; + } +})); +jest.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigateFn, +})); + +beforeEach(() => { + createClient.mockImplementation(() => { + return { + software: { + onProductChange: jest.fn() + }, + }; + }); +}); + +describe("ChangeProductButton", () => { + describe("when there is only a single product", () => { + beforeEach(() => { + mockProducts = [ + { id: "openSUSE", name: "openSUSE Tumbleweed" } + ]; + }); + + it("renders nothing", async () => { + const { container } = plainRender(); + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + }); + + describe("when there is more than one product", () => { + beforeEach(() => { + mockProducts = [ + { id: "openSUSE", name: "openSUSE Tumbleweed" }, + { id: "Leap Micro", name: "openSUSE Micro" } + ]; + }); + + it("renders a button for changing the selected product", async () => { + plainRender(); + + await screen.findByRole("button", { name: "Change selected product" }); + }); + + it("navigates to products route when users clicks on rendered button", async () => { + const { user } = plainRender(); + const changeProductButton = await screen.findByRole("button", { name: "Change selected product" }); + + await user.click(changeProductButton); + expect(mockNavigateFn).toHaveBeenCalledWith("/products"); + }); + + it("triggers given callback when user clicks on rendered button", async () => { + const onClickCallback = jest.fn(); + + const { user } = plainRender(); + const changeProductButton = await screen.findByRole("button", { name: "Change selected product" }); + + await user.click(changeProductButton); + expect(onClickCallback).toHaveBeenCalled(); + }); + }); +}); diff --git a/web/src/components/core/InstallationFinished.test.jsx b/web/src/components/core/InstallationFinished.test.jsx index 1ef9a7eed9..66551713c2 100644 --- a/web/src/components/core/InstallationFinished.test.jsx +++ b/web/src/components/core/InstallationFinished.test.jsx @@ -22,12 +22,13 @@ import React from "react"; import { screen } from "@testing-library/react"; -import { installerRender } from "@/test-utils"; +import { installerRender, mockLayout } from "@/test-utils"; import { createClient } from "@client"; import InstallationFinished from "./InstallationFinished"; jest.mock("@client"); +jest.mock("@components/layout/Layout", () => mockLayout()); const rebootSystemFn = jest.fn(); diff --git a/web/src/components/core/InstallationProgress.test.jsx b/web/src/components/core/InstallationProgress.test.jsx index 188e14af27..39a4015cb1 100644 --- a/web/src/components/core/InstallationProgress.test.jsx +++ b/web/src/components/core/InstallationProgress.test.jsx @@ -22,10 +22,11 @@ import React from "react"; import { screen } from "@testing-library/react"; -import { installerRender, mockComponent } from "@/test-utils"; +import { installerRender, mockComponent, mockLayout } from "@/test-utils"; import InstallationProgress from "./InstallationProgress"; +jest.mock("@components/layout/Layout", () => mockLayout()); jest.mock("@components/core/ProgressReport", () => mockComponent("ProgressReport Mock")); describe("InstallationProgress", () => { diff --git a/web/src/components/core/LogsButton.jsx b/web/src/components/core/LogsButton.jsx new file mode 100644 index 0000000000..c5331ca0fc --- /dev/null +++ b/web/src/components/core/LogsButton.jsx @@ -0,0 +1,120 @@ +/* + * Copyright (c) [2022] 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, { useState } from "react"; +import { useInstallerClient } from "@context/installer"; +import { useCancellablePromise } from "@/utils"; + +import { Alert, Button } from "@patternfly/react-core"; +import { Icon } from "@components/layout"; + +const FILENAME = "y2logs.tar.xz"; +const FILETYPE = "application/x-xz"; + +/** + * Button for collecting and donwloading YaST logs + * + * @component + * + * @param {object} props + */ +const LogsButton = ({ ...props }) => { + const client = useInstallerClient(); + const { cancellablePromise } = useCancellablePromise(); + const [isCollecting, setIsCollecting] = useState(false); + const [error, setError] = useState(null); + + /** + * Helper function for creating the blob and triggering the download automatically + * + * @note Based on the article "Programmatic file downloads in the browser" found at + * https://blog.logrocket.com/programmatic-file-downloads-in-the-browser-9a5186298d5c + * + * @param {Uint8Array} data - binary data for creating a {@link https://developer.mozilla.org/en-US/docs/Web/API/Blob Blob} + */ + const download = (data) => { + const blob = new Blob([data], { type: FILETYPE }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = FILENAME; + + // Click handler that releases the object URL after the element has been clicked + // This is required to let the browser know not to keep the reference to the file any longer + // See https://developer.mozilla.org/en-US/docs/Web/API/URL/revokeObjectURL + const clickHandler = () => { + setTimeout(() => { + URL.revokeObjectURL(url); + a.removeEventListener('click', clickHandler); + }, 150); + }; + + // Add the click event listener on the anchor element + a.addEventListener('click', clickHandler, false); + + // Programmatically trigger a click on the anchor element + // Needed for make the download to happen automatically without attaching the anchor element to + // the DOM + a.click(); + }; + + const collectAndDownload = () => { + setError(null); + setIsCollecting(true); + cancellablePromise(client.manager.fetchLogs()) + .then(download) + .catch(setError) + .finally(() => setIsCollecting(false)); + }; + + return ( + <> + + + { isCollecting && + } + + { error && + } + + ); +}; + +export default LogsButton; diff --git a/web/src/components/core/LogsButton.test.jsx b/web/src/components/core/LogsButton.test.jsx new file mode 100644 index 0000000000..a842ba2e31 --- /dev/null +++ b/web/src/components/core/LogsButton.test.jsx @@ -0,0 +1,148 @@ +/* + * Copyright (c) [2022] 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 { createClient } from "@client"; +import { LogsButton } from "@components/core"; + +jest.mock("@client"); + +const originalCreateElement = document.createElement; + +const executor = jest.fn(); +const fetchLogsFn = jest.fn().mockImplementation(() => new Promise(executor)); + +beforeEach(() => { + window.URL.createObjectURL = jest.fn(() => "fake-blob-url"); + window.URL.revokeObjectURL = jest.fn(); + + createClient.mockImplementation(() => { + return { + manager: { + fetchLogs: fetchLogsFn, + } + }; + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); + window.URL.createObjectURL.mockRestore(); + window.URL.revokeObjectURL.mockRestore(); +}); + +describe("LogsButton", () => { + it("renders a button for downloading logs", () => { + installerRender(); + screen.getByRole("button", "Download logs"); + }); + + describe("when user clicks on it", () => { + it("inits download logs process", async () => { + const { user } = installerRender(); + const button = screen.getByRole("button", "Download logs"); + await user.click(button); + expect(fetchLogsFn).toHaveBeenCalled(); + }); + + it("changes button text, puts it as disabled, and displays an informative alert", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", "Download logs"); + expect(button).not.toHaveAttribute("disabled"); + + await user.click(button); + + expect(button.innerHTML).not.toContain("Download logs"); + expect(button.innerHTML).toContain("Collecting logs..."); + expect(button).toHaveAttribute("disabled"); + + const info = screen.queryByRole("heading", { name: /.*logs download as soon as.*/i }); + const warning = screen.queryByRole("heading", { name: /.*went wrong*/i }); + + expect(info).toBeInTheDocument(); + expect(warning).not.toBeInTheDocument(); + }); + + describe("and logs are collected successfully", () => { + beforeEach(() => { + // new TextEncoder().encode("Hello logs!") + const data = new Uint8Array([72, 101, 108, 108, 111, 32, 108, 111, 103, 115, 33]); + fetchLogsFn.mockResolvedValue(data); + }); + + it("triggers the download", async () => { + const { user } = installerRender(); + + // Ugly mocking needed here. + // Improvements are wanted and welcome. + // NOTE: document.createElement cannot mocked in beforeAll because it breaks all testsuite + // since its used internally by jsdom. Simply spying it is not enough because we want to + // mock only the call to the HTMLAnchorElement creation that happens when user clicks on the + // "Download logs". + document._createElement = document.createElement; + + const anchorMock = document.createElement('a'); + anchorMock.setAttribute = jest.fn(); + anchorMock.click = jest.fn(); + + jest.spyOn(document, "createElement").mockImplementation((tag) => { + return (tag === 'a') ? anchorMock : document._createElement(tag); + }); + + // Now, let's simulate the "Download logs" user click + const button = screen.getByRole("button", "Download logs"); + await user.click(button); + + // And test what we're looking for + expect(document.createElement).toHaveBeenCalledWith('a'); + expect(anchorMock).toHaveAttribute("href", "fake-blob-url"); + expect(anchorMock).toHaveAttribute("download", expect.stringMatching(/y2logs/)); + expect(anchorMock.click).toHaveBeenCalled(); + + // Be polite and restore document.createElement function, + // although it should be done by the call to jest.restoreAllMocks() + // in the afterAll block + document.createElement = originalCreateElement; + }); + }); + + describe("but the process fails", () => { + beforeEach(() => { + fetchLogsFn.mockRejectedValue("Sorry, something went wrong"); + }); + + it("displays a warning alert along with the Download logs button", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", "Download logs"); + expect(button).not.toHaveAttribute("disabled"); + + await user.click(button); + + expect(button.innerHTML).toContain("Download logs"); + screen.getByRole("heading", { name: /.*went wrong.*try again.*/i }); + }); + }); + }); +}); diff --git a/web/src/components/core/Sidebar.jsx b/web/src/components/core/Sidebar.jsx new file mode 100644 index 0000000000..88726d359b --- /dev/null +++ b/web/src/components/core/Sidebar.jsx @@ -0,0 +1,77 @@ +/* + * Copyright (c) [2022] 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, { useState } from "react"; +import { Icon, PageActions } from "@components/layout"; +import { About, ChangeProductButton, LogsButton } from "@components/core"; +import { TargetIpsPopup } from "@components/network"; + +/** + * D-Installer sidebar navigation + */ +export default function Sidebar() { + const [isOpen, setIsOpen] = useState(false); + + const open = (e) => { + // Avoid the link navigating to the initial route + e.preventDefault(); + + setIsOpen(true); + }; + const close = () => setIsOpen(false); + + return ( + <> + + + + + + + + + ); +} diff --git a/web/src/components/core/Sidebar.test.jsx b/web/src/components/core/Sidebar.test.jsx new file mode 100644 index 0000000000..7660fd7f7d --- /dev/null +++ b/web/src/components/core/Sidebar.test.jsx @@ -0,0 +1,100 @@ +/* + * Copyright (c) [2022] 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, within, createEvent, fireEvent } from "@testing-library/react"; +import { plainRender, mockComponent, mockLayout } from "@/test-utils"; +import { Sidebar } from "@components/core"; + +jest.mock("@components/layout/Layout", () => mockLayout()); +jest.mock("@components/core/About", () => mockComponent("About Mock")); +jest.mock("@components/core/ChangeProductButton", () => mockComponent("ChangeProductButton Mock")); +jest.mock("@components/core/LogsButton", () => mockComponent("LogsButton Mock")); +jest.mock("@components/network/TargetIpsPopup", () => mockComponent("Host Ips Mock")); + +it("renders the sidebar initially hidden", async () => { + plainRender(); + const nav = await screen.findByRole("navigation", { name: /options/i }); + expect(nav).toHaveAttribute("data-state", "hidden"); +}); + +it("renders a link for displaying the sidebar", async () => { + const { user } = plainRender(); + + const link = await screen.findByLabelText(/Open/i); + const nav = await screen.findByRole("navigation", { name: /options/i }); + + expect(nav).toHaveAttribute("data-state", "hidden"); + await user.click(link); + expect(nav).toHaveAttribute("data-state", "visible"); +}); + +// Test that opening the sidebar does not navigate to the initial route +// This is achive by preventing the default link click behavior +// Read https://testing-library.com/docs/dom-testing-library/api-events#fireevent and +// https://developer.mozilla.org/en-US/docs/Web/API/Event/defaultPrevented +it("prevents the default event when the user click on the open link", async () => { + plainRender(); + const link = await screen.findByLabelText(/Open/i); + const clickEvent = createEvent.click(link); + fireEvent(link, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); +}); + +it("renders a link for hidding the sidebar", async () => { + const { user } = plainRender(); + + const openLink = await screen.findByLabelText(/Open/i); + const closeLink = await screen.findByLabelText(/Close/i); + + const nav = await screen.findByRole("navigation", { name: /options/i }); + + await user.click(openLink); + expect(nav).toHaveAttribute("data-state", "visible"); + await user.click(closeLink); + expect(nav).toHaveAttribute("data-state", "hidden"); +}); + +describe("Sidebar content", () => { + it("contains the component for changing the selected product", async () => { + plainRender(); + const nav = await screen.findByRole("navigation", { name: /options/i }); + await within(nav).findByText("ChangeProductButton Mock"); + }); + + it("contains the component for displaying the 'About' information", async () => { + plainRender(); + const nav = await screen.findByRole("navigation", { name: /options/i }); + await within(nav).findByText("About Mock"); + }); + + it("contains the component for displaying the 'Host Ips' information", async () => { + plainRender(); + const nav = await screen.findByRole("navigation", { name: /options/i }); + await within(nav).findByText("Host Ips Mock"); + }); + + it("contains the components for downloading the logs", async () => { + plainRender(); + const nav = await screen.findByRole("navigation", { name: /options/i }); + await within(nav).findByText("LogsButton Mock"); + }); +}); diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js index 22453fa8b6..21795fdc93 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -20,6 +20,7 @@ */ export { default as About } from "./About"; +export { default as Sidebar } from "./Sidebar"; export { default as Section } from "./Section"; export { default as FormLabel } from "./FormLabel"; export { default as Fieldset } from "./Fieldset"; @@ -28,7 +29,9 @@ export { default as InstallationProgress } from "./InstallationProgress"; export { default as InstallButton } from "./InstallButton"; export { default as InstallerSkeleton } from "./InstallerSkeleton"; export { default as KebabMenu } from "./KebabMenu"; +export { default as LogsButton } from "./LogsButton"; export { default as PasswordAndConfirmationInput } from "./PasswordAndConfirmationInput"; export { default as Popup } from "./Popup"; export { default as ProgressReport } from "./ProgressReport"; +export { default as ChangeProductButton } from "./ChangeProductButton"; export { default as ValidationErrors } from "./ValidationErrors"; diff --git a/web/src/components/layout/DBusError.test.jsx b/web/src/components/layout/DBusError.test.jsx index 99fd29ec80..269c29224d 100644 --- a/web/src/components/layout/DBusError.test.jsx +++ b/web/src/components/layout/DBusError.test.jsx @@ -22,11 +22,11 @@ import React from "react"; import { screen } from "@testing-library/react"; -import { plainRender, mockComponent } from "@/test-utils"; +import { plainRender, mockLayout } from "@/test-utils"; import { DBusError } from "@components/layout"; -jest.mock("@components/network/TargetIpsPopup", () => mockComponent("IP Mock")); +jest.mock("@components/layout/Layout", () => mockLayout()); describe("DBusError", () => { it("includes a generic D-Bus connection problem message", () => { diff --git a/web/src/components/layout/Icon.jsx b/web/src/components/layout/Icon.jsx index 333b9854b8..8999df13bd 100644 --- a/web/src/components/layout/Icon.jsx +++ b/web/src/components/layout/Icon.jsx @@ -28,8 +28,12 @@ import Translate from "~icons/translate.svg?component"; import SettingsEthernet from "~icons/settings_ethernet.svg?component"; import EditSquare from "~icons/edit_square.svg?component"; import Edit from "~icons/edit.svg?component"; +import Download from "~icons/download.svg?component"; import HardDrive from "~icons/hard_drive.svg?component"; +import Help from "~icons/help.svg?component"; import ManageAccounts from "~icons/manage_accounts.svg?component"; +import Menu from "~icons/menu.svg?component"; +import MenuOpen from "~icons/menu_open.svg?component"; import HomeStorage from "~icons/home_storage.svg?component"; import Problem from "~icons/problem.svg?component"; import Error from "~icons/error.svg?component"; @@ -53,11 +57,13 @@ const icons = { apps: Apps, check_circle: CheckCircle, delete: Delete, + download: Download, downloading: Downloading, edit: Edit, edit_square: EditSquare, error: Error, hard_drive: HardDrive, + help: Help, home_storage: HomeStorage, info: Info, inventory_2: Inventory, @@ -65,6 +71,8 @@ const icons = { loading: Loading, lock: Lock, manage_accounts: ManageAccounts, + menu: Menu, + menu_open: MenuOpen, more_vert: MoreVert, problem: Problem, settings: SettingsFill, diff --git a/web/src/components/layout/Layout.jsx b/web/src/components/layout/Layout.jsx index 49f34d5013..da7a2fbfcd 100644 --- a/web/src/components/layout/Layout.jsx +++ b/web/src/components/layout/Layout.jsx @@ -23,6 +23,7 @@ import React from "react"; import logoUrl from "@assets/suse-horizontal-logo.svg"; import { createTeleporter } from "react-teleporter"; +import { Sidebar } from "@components/core"; const PageTitle = createTeleporter(); const HeaderActions = createTeleporter(); @@ -31,10 +32,20 @@ const FooterActions = createTeleporter(); const FooterInfoArea = createTeleporter(); /** - * D-Installer main layout component. * - * It displays the content in a single vertical responsive column with fixed - * header and footer. + * The D-Installer main layout component. + * + * It displays the content in a single column with a fixed header and footer. + * + * To achieve a {@link https://gregberge.com/blog/react-scalable-layout scalable layout}, + * it uses {@link https://reactjs.org/docs/portals.html React Portals} through + * {@link https://github.com/gregberge/react-teleporter react-teleporter}. In other words, + * it is mounted only once and gets influenced by other components by using the created + * slots (Title, PageIcon, MainActions, etc). + * + * So, please ensure that {@link test-utils!mockLayout } gets updated when adding or deleting + * slots here. It's needed in order to allow testing the output of components that interact + * with the layout using that mechanism. * * @example * @@ -67,14 +78,17 @@ function Layout({ children }) { -
{children}
+ + +
+ {children} +
- Logo of SUSE
diff --git a/web/src/components/network/ConnectionsDataList.test.jsx b/web/src/components/network/ConnectionsDataList.test.jsx index 659058d933..3ef2ddae16 100644 --- a/web/src/components/network/ConnectionsDataList.test.jsx +++ b/web/src/components/network/ConnectionsDataList.test.jsx @@ -46,14 +46,14 @@ const conns = [wiredConnection, wiFiConnection]; describe("ConnectionsDataList", () => { describe("when no connections are given", () => { it("renders nothing", () => { - const { container } = plainRender(, { usingLayout: false }); + const { container } = plainRender(); expect(container).toBeEmptyDOMElement(); }); }); describe("when a list of connections are given", () => { it("renders a list with the name and the IPv4 addresses of each connection", () => { - plainRender(, { usingLayout: false }); + plainRender(); screen.getByText("Wired 1"); screen.getByText("WiFi 1"); @@ -65,7 +65,7 @@ describe("ConnectionsDataList", () => { describe("when the user clicks on a connection", () => { it("calls the onSelect function", async () => { const onSelect = jest.fn(); - const { user } = plainRender(, { usingLayout: false }); + const { user } = plainRender(); const connection = screen.getByRole("button", { name: "WiFi 1" }); await user.click(connection); expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "wifi-1" })); diff --git a/web/src/components/network/Network.test.jsx b/web/src/components/network/Network.test.jsx index bd4b8a7e46..dc25a905d0 100644 --- a/web/src/components/network/Network.test.jsx +++ b/web/src/components/network/Network.test.jsx @@ -69,7 +69,7 @@ describe("Network", () => { describe("when it has not been initialized", () => { it("renders nothing", async () => { - const { container } = installerRender(, { usingLayout: false }); + const { container } = installerRender(); await waitFor(() => expect(container).toBeEmptyDOMElement()); }); }); @@ -98,7 +98,7 @@ describe("Network", () => { describe("when Wireless is currently not enabled", () => { it("does not show a link to open the WiFi selector", async () => { - installerRender(, { usingLayout: false }); + installerRender(); await waitFor(() => expect(screen.queryByRole("button", { name: "Connect to a Wi-Fi network" })).not.toBeInTheDocument()); }); }); diff --git a/web/src/components/network/TargetIpsPopup.jsx b/web/src/components/network/TargetIpsPopup.jsx index b3f30d04e5..42123fa4c3 100644 --- a/web/src/components/network/TargetIpsPopup.jsx +++ b/web/src/components/network/TargetIpsPopup.jsx @@ -22,13 +22,14 @@ import React, { useEffect, useState } from "react"; import { Button, List, ListItem, Text } from "@patternfly/react-core"; -import { useCancellablePromise } from "@/utils"; +import { noop, useCancellablePromise } from "@/utils"; import { useInstallerClient } from "@context/installer"; import { formatIp } from "@client/network/utils"; +import { Icon } from "@components/layout"; import { Popup } from "@components/core"; -export default function TargetIpsPopup() { +export default function TargetIpsPopup({ onClickCallback = noop }) { const client = useInstallerClient(); const { cancellablePromise } = useCancellablePromise(); const [addresses, setAddresses] = useState([]); @@ -57,12 +58,21 @@ export default function TargetIpsPopup() { if (addresses.length === 0) return null; const [firstIp] = addresses; - const open = () => setIsOpen(true); + const open = () => { + setIsOpen(true); + onClickCallback(); + }; + const close = () => setIsOpen(false); return ( <> - diff --git a/web/src/components/network/TargetIpsPopup.test.jsx b/web/src/components/network/TargetIpsPopup.test.jsx index 9e003dfb81..567ca870b8 100644 --- a/web/src/components/network/TargetIpsPopup.test.jsx +++ b/web/src/components/network/TargetIpsPopup.test.jsx @@ -73,6 +73,16 @@ describe("TargetIpsPopup", () => { }); }); + it("triggers onClickCallback function when popup is open", async () => { + const onClickCallback = jest.fn(); + + const { user } = installerRender(); + + const button = await screen.findByRole("button", { name: /1.2.3.4\/24 \(example.net\)/i }); + await user.click(button); + expect(onClickCallback).toHaveBeenCalled(); + }); + it("updates address and hostname if they change", async () => { installerRender(); await screen.findByRole("button", { name: /1.2.3.4\/24 \(example.net\)/i }); diff --git a/web/src/components/network/WifiNetworksList.test.jsx b/web/src/components/network/WifiNetworksList.test.jsx index 06d4b53ff9..6467eec6f7 100644 --- a/web/src/components/network/WifiNetworksList.test.jsx +++ b/web/src/components/network/WifiNetworksList.test.jsx @@ -45,7 +45,7 @@ const networksMock = [myNetwork, otherNetwork]; describe("WifiNetworksList", () => { it("renders link for connect to a hidden network", () => { - installerRender(, { usingLayout: false }); + installerRender(); screen.getByRole("button", { name: "Connect to hidden network" }); }); diff --git a/web/src/components/overview/Overview.jsx b/web/src/components/overview/Overview.jsx index 6aa4412842..f21029f2c3 100644 --- a/web/src/components/overview/Overview.jsx +++ b/web/src/components/overview/Overview.jsx @@ -20,37 +20,16 @@ */ import React, { useState } from "react"; -import { useNavigate, Navigate } from "react-router-dom"; import { useSoftware } from "@context/software"; +import { Navigate } from "react-router-dom"; -import { Button } from "@patternfly/react-core"; - -import { Icon, Title, PageIcon, PageActions, MainActions } from "@components/layout"; +import { Icon, Title, PageIcon, MainActions } from "@components/layout"; import { Section, InstallButton } from "@components/core"; import { LanguageSelector } from "@components/language"; import { SoftwareSection, StorageSection } from "@components/overview"; import { Users } from "@components/users"; import { Network } from "@components/network"; -const ChangeProductButton = () => { - const { products } = useSoftware(); - const navigate = useNavigate(); - - if (products === undefined || products.length === 1) { - return ""; - } - - return ( -