diff --git a/web/package/cockpit-agama.changes b/web/package/cockpit-agama.changes index 6a5e0f302a..d8ddc43df9 100644 --- a/web/package/cockpit-agama.changes +++ b/web/package/cockpit-agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Mon Apr 15 07:14:35 UTC 2024 - David Diaz + +- Enhance the storage page to make it easier to use and understand. + (gh#openSUSE/agama#1138). + ------------------------------------------------------------------- Thu Apr 11 15:16:42 UTC 2024 - José Iván López González diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index 7b4bd2874e..9bf8faff5c 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -719,7 +719,6 @@ section [data-type="agama/reminder"] { } } - [data-type="agama/expandable-selector"] { // The expandable selector is built on top of PF/Table#expandable // Let's tweak some styles @@ -743,6 +742,81 @@ section [data-type="agama/reminder"] { } } +[data-type="agama/field"] { + > div:first-child { + font-size: var(--fs-large); + + button { + padding-inline: 0; + } + + button:hover { + color: var(--color-link-hover); + fill: var(--color-link-hover); + } + + button b, button:hover b { + text-decoration: underline; + text-underline-offset: var(--spacer-smaller); + } + + div.pf-v5-c-skeleton { + display: inline-block; + vertical-align: middle; + height: 1.5ex; + } + } + + > div:nth-child(n+2) { + margin-inline-start: calc(var(--icon-size-s) + 1ch); + } + + > div:nth-child(2) { + color: gray; + font-size: var(--fs-medium); + } + + > div:nth-child(n+3) { + margin-block-start: var(--spacer-small); + } + + &.highlighted > div:last-child { + --spacing: calc(var(--icon-size-s) / 2); + margin-inline: var(--spacing); + padding-inline: var(--spacing); + border-inline-start: 2px solid; + } + + &.highlighted.on > div:last-child { + border-color: var(--color-link-hover); + } + + &.highlighted.off > div:last-child { + border-color: var(--color-gray-darker); + } + + &.on { + button:not(.password-toggler) { + fill: var(--color-link-hover); + } + } + + hr { + margin-block: var(--spacer-normal); + border: 0; + border-bottom: thin dashed var(--color-gray); + } +} + +[data-type="agama/field"] button.pf-v5-c-menu-toggle.pf-m-plain { + padding: 0; +} + +[data-type="agama/field"] .pf-v5-c-menu__list { + padding: calc(var(--spacer-smaller) / 2) 0; + margin: 0; +} + #boot-form { legend { label { diff --git a/web/src/assets/styles/utilities.scss b/web/src/assets/styles/utilities.scss index ae12b38719..f4303c8617 100644 --- a/web/src/assets/styles/utilities.scss +++ b/web/src/assets/styles/utilities.scss @@ -136,6 +136,15 @@ color: inherit; font: inherit; padding: 0; + text-align: start; +} + +.inline-flex-button{ + @extend .plain-button; + display: inline-flex; + align-items: center; + gap: 0.7ch; + text-decoration: underline; } .p-0 { diff --git a/web/src/components/core/Field.jsx b/web/src/components/core/Field.jsx new file mode 100644 index 0000000000..74d955733a --- /dev/null +++ b/web/src/components/core/Field.jsx @@ -0,0 +1,121 @@ +/* + * 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 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. + */ + +// @ts-check + +import React from "react"; +import { Icon } from "~/components/layout"; + +/** + * @typedef {import("react").ButtonHTMLAttributes} ButtonHTMLAttributes + * @typedef {import("~/components/layout/Icon").IconName} IconName + * @typedef {import("~/components/layout/Icon").IconSize} IconSize + */ + +/** + * @typedef {object} FieldProps + * @property {React.ReactNode} label - The field label. + * @property {React.ReactNode} [value] - The field value. + * @property {React.ReactNode} [description] - A field description, useful for providing context to the user. + * @property {IconName} [icon] - The name of the icon for the field. + * @property {IconSize} [iconSize="s"] - The size for the field icon. + * @property {("b"|"span")} [textWrapper="b"] - The element used for wrapping the label. + * @property {ButtonHTMLAttributes} [buttonAttrs={}] - The element used for wrapping the label. + * @property {string} [className] - ClassName + * @property {() => void} [onClick] - Callback + * @property {React.ReactNode} [children] - A content to be rendered as field children + * + * @typedef {Omit} FieldPropsWithoutIcon + */ + +/** + * Component for laying out a page field + * + * @param {FieldProps} props + */ +const Field = ({ + label, + value, + description, + icon, + iconSize = "s", + onClick, + children, + textWrapper = "b", + buttonAttrs = {}, + ...props +}) => { + const FieldIcon = () => icon?.length > 0 && ; + const TextWrapper = textWrapper; + return ( +
+
+ {value} +
+
+ {description} +
+
+ {children} +
+
+ ); +}; + +/** + * @param {Omit} props + */ +const SettingsField = ({ ...props }) => { + return ; +}; + +/** + * @param {Omit & {isChecked: boolean, highlightContent?: boolean}} props + */ +const SwitchField = ({ isChecked = false, highlightContent = false, ...props }) => { + const iconName = isChecked ? "toggle_on" : "toggle_off"; + const baseClassnames = highlightContent ? "highlighted" : ""; + const stateClassnames = isChecked ? "on" : "off"; + + return ( + + ); +}; + +/** + * @param {Omit & {isExpanded: boolean}} props + */ +const ExpandableField = ({ isExpanded, ...props }) => { + const iconName = isExpanded ? "collapse_all" : "expand_all"; + const className = isExpanded ? "expanded" : "collapsed"; + + return ; +}; + +export default Field; +export { ExpandableField, SettingsField, SwitchField }; diff --git a/web/src/components/core/Field.test.jsx b/web/src/components/core/Field.test.jsx new file mode 100644 index 0000000000..2a07467732 --- /dev/null +++ b/web/src/components/core/Field.test.jsx @@ -0,0 +1,129 @@ +/* + * 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 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 { plainRender } from "~/test-utils"; +import { Field, ExpandableField, SettingsField, SwitchField } from "~/components/core"; + +const onClick = jest.fn(); + +describe("Field", () => { + it("renders a button with given icon and label", () => { + const { container } = plainRender( + + ); + screen.getByRole("button", { name: "Theme" }); + const icon = container.querySelector("button > svg"); + expect(icon).toHaveAttribute("data-icon-name", "edit"); + }); + + it("renders value, description, and given children", () => { + plainRender( + +

This is a preview

; +
+ ); + screen.getByText("dark"); + screen.getByText("Choose your preferred color schema."); + screen.getByText("This is a"); + screen.getByText("preview"); + }); + + it("triggers the onClick callback when users clicks the button", async () => { + const { user } = plainRender( + + ); + const button = screen.getByRole("button"); + await user.click(button); + expect(onClick).toHaveBeenCalled(); + }); +}); + +describe("SettingsField", () => { + it("uses the 'shadow' icon", () => { + const { container } = plainRender( + // Trying to set other icon, although typechecking should catch it. + + ); + const icon = container.querySelector("button > svg"); + expect(icon).toHaveAttribute("data-icon-name", "shadow"); + }); +}); + +describe("SwitchField", () => { + it("sets button role to switch", () => { + plainRender(); + const switchButton = screen.getByRole("switch", { name: "Zoom" }); + expect(switchButton instanceof HTMLButtonElement).toBe(true); + }); + + it("keeps aria-checked attribute in sync with isChecked prop", () => { + let switchButton; + const { rerender } = plainRender(); + switchButton = screen.getByRole("switch", { name: "Zoom" }); + expect(switchButton).toHaveAttribute("aria-checked", "true"); + + rerender(); + switchButton = screen.getByRole("switch", { name: "Zoom" }); + expect(switchButton).toHaveAttribute("aria-checked", "false"); + }); + + it("uses the 'toggle_on' icon when isChecked", () => { + const { container } = plainRender( + + ); + const icon = container.querySelector("button > svg"); + expect(icon).toHaveAttribute("data-icon-name", "toggle_on"); + }); + + it("uses the 'toggle_off' icon when not isChecked", () => { + const { container } = plainRender( + + ); + const icon = container.querySelector("button > svg"); + expect(icon).toHaveAttribute("data-icon-name", "toggle_off"); + }); +}); + +describe("ExpandableField", () => { + it("uses the 'collapse_all' icon when isExpanded", () => { + const { container } = plainRender( + + ); + const icon = container.querySelector("button > svg"); + expect(icon).toHaveAttribute("data-icon-name", "collapse_all"); + }); + + it("uses the 'expand_all' icon when not isExpanded", () => { + const { container } = plainRender( + + ); + const icon = container.querySelector("button > svg"); + expect(icon).toHaveAttribute("data-icon-name", "expand_all"); + }); +}); diff --git a/web/src/components/core/PageMenu.jsx b/web/src/components/core/PageMenu.jsx index ac4c3f432a..608c9a0384 100644 --- a/web/src/components/core/PageMenu.jsx +++ b/web/src/components/core/PageMenu.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -19,6 +19,8 @@ * find current contact information at www.suse.com. */ +// @ts-check + import React, { useState } from 'react'; import { Dropdown, DropdownGroup, DropdownItem, DropdownList, @@ -31,18 +33,23 @@ import { Icon } from "~/components/layout"; * Internal component to build the {PageMenu} toggler * @component * - * @param {object} props - * @param {string} [props.aria-label="Show page menu"] - * @param {function} props.onClick + * @typedef {object} TogglerBaseProps + * @property {React.Ref} toggleRef + * @property {string} label + * + * @typedef {TogglerBaseProps & import('@patternfly/react-core').MenuToggleProps} TogglerProps + * + * @param {TogglerProps} props */ -const Toggler = ({ toggleRef, onClick, "aria-label": ariaLabel = _(("Show page menu")) }) => { +const Toggler = ({ toggleRef, label, onClick, "aria-label": ariaLabel = _(("Show page menu")) }) => { return ( + {label} ); @@ -54,9 +61,9 @@ const Toggler = ({ toggleRef, onClick, "aria-label": ariaLabel = _(("Show page m * * Built on top of {@link https://www.patternfly.org/components/menus/dropdown#dropdowngroup PF/DropdownGroup} * - * @see {PageMenu } examples. + * @see {PageMenu} examples. * - * @param {object} props - PF/DropdownGroup props, See {@link https://www.patternfly.org/components/menus/dropdown#dropdowngroup} + * @param {import('@patternfly/react-core').DropdownGroupProps} props */ const Group = ({ children, ...props }) => { return ( @@ -74,7 +81,7 @@ const Group = ({ children, ...props }) => { * * @see {PageMenu} examples. * - * @param {object} props - PF/DropdownItem props, See {@link https://www.patternfly.org/components/menus/dropdown#dropdownitem} + * @param {import('@patternfly/react-core').DropdownItemProps} props */ const Option = ({ children, ...props }) => { return ( @@ -92,7 +99,7 @@ const Option = ({ children, ...props }) => { * * @see {PageMenu} examples. * - * @param {object} props - PF/DropdownList props, See {@link https://www.patternfly.org/components/menus/dropdown#dropdownlist} + * @param {import('@patternfly/react-core').DropdownListProps} props */ const Options = ({ children, ...props }) => { return ( @@ -147,10 +154,14 @@ const Options = ({ children, ...props }) => { * * * - * @param {object} props - * @param {Group|Item|Array} props.children + * @typedef {object} PageMenuProps + * @property {string} [togglerAriaLabel] + * @property {string} label + * @property {React.ReactNode} children + * + * @param {PageMenuProps} props */ -const PageMenu = ({ togglerAriaLabel, children }) => { +const PageMenu = ({ togglerAriaLabel, label, children }) => { const [isOpen, setIsOpen] = useState(false); const toggle = () => setIsOpen(!isOpen); @@ -159,14 +170,14 @@ const PageMenu = ({ togglerAriaLabel, children }) => { return ( } + toggle={(toggleRef) => } onSelect={close} onOpenChange={close} popperProps={{ minWidth: "150px", position: "right" }} data-type="agama/page-menu" > - {Array(children)} + {children} ); diff --git a/web/src/components/core/PasswordAndConfirmationInput.jsx b/web/src/components/core/PasswordAndConfirmationInput.jsx index 90fb19132a..9cdfbc5d0e 100644 --- a/web/src/components/core/PasswordAndConfirmationInput.jsx +++ b/web/src/components/core/PasswordAndConfirmationInput.jsx @@ -19,7 +19,9 @@ * find current contact information at www.suse.com. */ -import React, { useState } from "react"; +// @ts-check + +import React, { useEffect, useState } from "react"; import { FormGroup } from "@patternfly/react-core"; import { FormValidationError, PasswordInput } from "~/components/core"; import { _ } from "~/i18n"; @@ -28,6 +30,10 @@ const PasswordAndConfirmationInput = ({ value, onChange, onValidation, isDisable const [confirmation, setConfirmation] = useState(value || ""); const [error, setError] = useState(""); + useEffect(() => { + if (isDisabled) setError(""); + }, [isDisabled]); + const validate = (password, passwordConfirmation) => { let newError = ""; @@ -57,7 +63,6 @@ const PasswordAndConfirmationInput = ({ value, onChange, onValidation, isDisable { expect(passwordInput.value).toEqual(confirmationInput.value); }); -it("disables both, password and confirmation, when isDisabled prop is given", async () => { - plainRender( - - ); +describe("when isDisabled", () => { + it("disables both, password and confirmation", async () => { + plainRender( + + ); - const passwordInput = screen.getByLabelText("Password"); - const confirmationInput = screen.getByLabelText("Password confirmation"); + const passwordInput = screen.getByLabelText("Password"); + const confirmationInput = screen.getByLabelText("Password confirmation"); + + expect(passwordInput).toBeDisabled(); + expect(confirmationInput).toBeDisabled(); + }); + + it("clean errors", async () => { + const CleanErrorTest = () => { + const [isDisabled, setIsDisabled] = React.useState(false); - expect(passwordInput).toBeDisabled(); - expect(confirmationInput).toBeDisabled(); + return ( + <> + + + + ); + }; + + const { user } = plainRender(); + const passwordInput = screen.getByLabelText("Password"); + user.type(passwordInput, "123456"); + await screen.findByText("Passwords do not match"); + const setAsDisabledButton = screen.getByRole("button", { name: "Set as disabled" }); + await user.click(setAsDisabledButton); + expect(screen.queryByText("Passwords do not match")).toBeNull(); + }); }); diff --git a/web/src/components/core/Section.jsx b/web/src/components/core/Section.jsx index 831d5f792d..c1e80cd63b 100644 --- a/web/src/components/core/Section.jsx +++ b/web/src/components/core/Section.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -26,6 +26,10 @@ import { Link } from "react-router-dom"; import { Icon } from '~/components/layout'; import { If, ValidationErrors } from "~/components/core"; +/** + * @typedef {import("~/components/layout/Icon").IconName} IconName + */ + /** * Renders children into an HTML section * @component @@ -46,7 +50,7 @@ import { If, ValidationErrors } from "~/components/core"; * * * @typedef { Object } SectionProps - * @property {string} [icon] - Name of the section icon. Not rendered if title is not provided. + * @property {IconName} [icon] - Name of the section icon. Not rendered if title is not provided. * @property {string} [title] - The section title. If not given, aria-label must be provided. * @property {string|React.ReactElement} [description] - A section description. Use only if really needed. * @property {string} [name] - The section name. Used to build the header id. diff --git a/web/src/components/core/Section.test.jsx b/web/src/components/core/Section.test.jsx index f132a637a6..ede96d0118 100644 --- a/web/src/components/core/Section.test.jsx +++ b/web/src/components/core/Section.test.jsx @@ -19,18 +19,23 @@ * find current contact information at www.suse.com. */ +// @ts-check + import React from "react"; import { screen, within } from "@testing-library/react"; import { plainRender, installerRender } from "~/test-utils"; import { Section } from "~/components/core"; +let consoleErrorSpy; + describe("Section", () => { beforeAll(() => { - jest.spyOn(console, "error").mockImplementation(); + consoleErrorSpy = jest.spyOn(console, "error"); + consoleErrorSpy.mockImplementation(); }); afterAll(() => { - console.error.mockRestore(); + consoleErrorSpy.mockRestore(); }); describe("when title is given", () => { @@ -60,7 +65,8 @@ describe("Section", () => { }); it("does not render an icon if not valid icon name is given", () => { - const { container } = plainRender(
); + // @ts-expect-error: Creating the icon name dynamically is unlikely, but let's be safe. + const { container } = plainRender(
); const icon = container.querySelector("svg"); expect(icon).toBeNull(); }); @@ -124,12 +130,14 @@ describe("Section", () => { it("sets predictable header id if name is given", () => { plainRender(
); - screen.getByRole("heading", { name: "Settings", id: "settings-header-section" }); + const section = screen.getByRole("heading", { name: "Settings" }); + expect(section).toHaveAttribute("id", "settings-section-header"); }); it("sets partially random header id if name is not given", () => { - plainRender(
); - screen.getByRole("heading", { name: "Settings", id: /.*(-header-section)$/ }); + plainRender(
); + const section = screen.getByRole("heading", { name: "Settings" }); + expect(section).toHaveAttribute("id", expect.stringContaining("section-header")); }); it("renders a polite live region", () => { diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js index 7e0a4837ff..c881137abb 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -62,3 +62,5 @@ export { default as Reminder } from "./Reminder"; export { default as Tag } from "./Tag"; export { default as TreeTable } from "./TreeTable"; export { default as ControlledPanels } from "./ControlledPanels"; +export { default as Field } from "./Field"; +export { ExpandableField, SettingsField, SwitchField } from "./Field"; diff --git a/web/src/components/layout/Icon.jsx b/web/src/components/layout/Icon.jsx index a14e646a66..848d4859cc 100644 --- a/web/src/components/layout/Icon.jsx +++ b/web/src/components/layout/Icon.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -28,6 +28,7 @@ import Apps from "@icons/apps.svg?component"; import Badge from "@icons/badge.svg?component"; import CheckCircle from "@icons/check_circle.svg?component"; import ChevronRight from "@icons/chevron_right.svg?component"; +import CollapseAll from "@icons/collapse_all.svg?component"; import Delete from "@icons/delete.svg?component"; import Description from "@icons/description.svg?component"; import Download from "@icons/download.svg?component"; @@ -35,7 +36,9 @@ import Downloading from "@icons/downloading.svg?component"; import Edit from "@icons/edit.svg?component"; import EditSquare from "@icons/edit_square.svg?component"; import Error from "@icons/error.svg?component"; +import ExpandAll from "@icons/expand_all.svg?component"; import ExpandMore from "@icons/expand_more.svg?component"; +import Feedback from "@icons/feedback.svg?component"; import Folder from "@icons/folder.svg?component"; import FolderOff from "@icons/folder_off.svg?component"; import Globe from "@icons/globe.svg?component"; @@ -59,11 +62,14 @@ import Schedule from "@icons/schedule.svg?component"; import SettingsApplications from "@icons/settings_applications.svg?component"; import SettingsEthernet from "@icons/settings_ethernet.svg?component"; import SettingsFill from "@icons/settings-fill.svg?component"; +import Shadow from "@icons/shadow.svg?component"; import SignalCellularAlt from "@icons/signal_cellular_alt.svg?component"; import Storage from "@icons/storage.svg?component"; import Sync from "@icons/sync.svg?component"; import TaskAlt from "@icons/task_alt.svg?component"; import Terminal from "@icons/terminal.svg?component"; +import ToggleOff from "@icons/toggle_off.svg?component"; +import ToggleOn from "@icons/toggle_on.svg?component"; import Translate from "@icons/translate.svg?component"; import Tune from "@icons/tune.svg?component"; import Warning from "@icons/warning.svg?component"; @@ -79,12 +85,18 @@ import { SiLinux, SiWindows } from "@icons-pack/react-simple-icons"; // Icons from SVG import Loading from "./three-dots-loader-icon.svg?component"; +/** + * @typedef {string|number} IconSize + * @typedef {keyof icons} IconName + */ + const icons = { add_a_photo: AddAPhoto, apps: Apps, badge: Badge, check_circle: CheckCircle, chevron_right: ChevronRight, + collapse_all: CollapseAll, delete: Delete, description: Description, download: Download, @@ -92,7 +104,9 @@ const icons = { edit: Edit, edit_square: EditSquare, error: Error, + expand_all: ExpandAll, expand_more: ExpandMore, + feedback: Feedback, folder: Folder, folder_off: FolderOff, globe: Globe, @@ -117,11 +131,14 @@ const icons = { settings: SettingsFill, settings_applications: SettingsApplications, settings_ethernet: SettingsEthernet, + shadow: Shadow, signal_cellular_alt: SignalCellularAlt, storage: Storage, sync: Sync, task_alt: TaskAlt, terminal: Terminal, + toggle_off: ToggleOff, + toggle_on: ToggleOn, translate: Translate, tune: Tune, visibility: Visibility, @@ -149,9 +166,9 @@ const PREDEFINED_SIZES = [ * * * @param {object} props - Component props - * @param {string} props.name - Name of the desired icon. + * @param {IconName} props.name - Name of the desired icon. * @param {string} [props.className=""] - CSS classes. - * @param {string|number} [props.size] - Size used for both, width and height. + * @param {IconSize} [props.size] - Size used for both, width and height. * It can be a CSS unit or one of PREDEFINED_SIZES. * @param {object} [props.otherProps] Other props sent to SVG icon. Please, note * that width and height will be overwritten by the size value if it was given. @@ -159,13 +176,9 @@ const PREDEFINED_SIZES = [ * @returns {JSX.Element|null} null if requested icon is not available or given a falsy value as name; JSX block otherwise. */ export default function Icon({ name, size, ...otherProps }) { - if (!name) { - console.error(`Icon called without name. '${name}' given instead. Rendering nothing.`); - return null; - } - - if (!icons[name]) { - console.error(`Icon '${name}' not found!`); + // NOTE: Reaching this is unlikely, but let's be safe. + if (!name || !icons[name]) { + console.error(`Icon '${name}' not found.`); return null; } diff --git a/web/src/components/layout/Icon.test.jsx b/web/src/components/layout/Icon.test.jsx index 2e7a707e88..bf2212b33c 100644 --- a/web/src/components/layout/Icon.test.jsx +++ b/web/src/components/layout/Icon.test.jsx @@ -19,17 +19,22 @@ * find current contact information at www.suse.com. */ +// @ts-check + import React from "react"; import { plainRender } from "~/test-utils"; import { Icon } from "~/components/layout"; +let consoleErrorSpy; + describe("Icon", () => { beforeAll(() => { - jest.spyOn(console, "error").mockImplementation(); + consoleErrorSpy = jest.spyOn(console, "error"); + consoleErrorSpy.mockImplementation(); }); afterAll(() => { - console.error.mockRestore(); + consoleErrorSpy.mockRestore(); }); describe("mounted with a known name", () => { @@ -70,6 +75,7 @@ describe("Icon", () => { describe("mounted with unknown name", () => { it("outputs to console.error", () => { + // @ts-expect-error: It's unlikely to happen, but let's test it anyway plainRender(); expect(console.error).toHaveBeenCalledWith( expect.stringContaining("'apsens' not found") @@ -77,6 +83,7 @@ describe("Icon", () => { }); it("renders nothing", async () => { + // @ts-expect-error: It's unlikely to happen, but let's test it anyway const { container } = plainRender(); expect(container).toBeEmptyDOMElement(); }); @@ -84,19 +91,19 @@ describe("Icon", () => { describe("mounted with a falsy value as name", () => { it("outputs to console.error", () => { + // @ts-expect-error: It's unlikely to happen, but let's test it anyway plainRender(); expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("Rendering nothing") + expect.stringContaining("not found") ); }); it("renders nothing", () => { - const { container: contentWhenNotDefined } = plainRender(); - expect(contentWhenNotDefined).toBeEmptyDOMElement(); - + // @ts-expect-error: It's unlikely to happen, but let's test it anyway const { container: contentWhenEmpty } = plainRender(); expect(contentWhenEmpty).toBeEmptyDOMElement(); + // @ts-expect-error: It's unlikely to happen, but let's test it anyway const { container: contentWhenFalse } = plainRender(); expect(contentWhenFalse).toBeEmptyDOMElement(); diff --git a/web/src/components/storage/BootConfigField.jsx b/web/src/components/storage/BootConfigField.jsx new file mode 100644 index 0000000000..da2d942b19 --- /dev/null +++ b/web/src/components/storage/BootConfigField.jsx @@ -0,0 +1,124 @@ +/* + * 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 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. + */ + +// @ts-check + +import React, { useState } from "react"; +import { Skeleton } from "@patternfly/react-core"; + +import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; +import { deviceLabel } from "~/components/storage/utils"; +import { If } from "~/components/core"; +import { Icon } from "~/components/layout"; +import BootSelectionDialog from "~/components/storage/BootSelectionDialog"; + +/** + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + */ + +/** + * Internal component for building the button that opens the dialog + * + * @param {object} props + * @param {boolean} [props.isBold=false] - Whether text should be wrapped by . + * @param {() => void} props.onClick - Callback to trigger when user clicks. + */ +const Button = ({ isBold = false, onClick }) => { + const text = _("Change boot options"); + + return ( + + ); +}; + +/** + * Allows to select the boot config. + * @component + * + * @param {object} props + * @param {boolean} props.configureBoot + * @param {StorageDevice|undefined} props.bootDevice + * @param {StorageDevice|undefined} props.defaultBootDevice + * @param {StorageDevice[]} props.devices + * @param {boolean} props.isLoading + * @param {(boot: BootConfig) => void} props.onChange + * + * @typedef {object} BootConfig + * @property {boolean} configureBoot + * @property {StorageDevice} bootDevice + */ +export default function BootConfigField ({ + configureBoot, + bootDevice, + defaultBootDevice, + devices, + isLoading, + onChange +}) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const openDialog = () => setIsDialogOpen(true); + + const closeDialog = () => setIsDialogOpen(false); + + const onAccept = ({ configureBoot, bootDevice }) => { + closeDialog(); + onChange({ configureBoot, bootDevice }); + }; + + if (isLoading) { + return ; + } + + let value; + + if (!configureBoot) { + value = <> {_("Installation will not configure partitions for booting.")}; + } else if (!bootDevice) { + value = _("Installation will configure partitions for booting at the installation disk."); + } else { + // TRANSLATORS: %s is the disk used to configure the boot-related partitions (eg. "/dev/sda, 80 GiB) + value = sprintf(_("Installation will configure partitions for booting at %s."), deviceLabel(bootDevice)); + } + + return ( +
+ { value }
+ ); +} diff --git a/web/src/components/storage/BootConfigField.test.jsx b/web/src/components/storage/BootConfigField.test.jsx new file mode 100644 index 0000000000..036db095ab --- /dev/null +++ b/web/src/components/storage/BootConfigField.test.jsx @@ -0,0 +1,112 @@ +/* + * 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 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. + */ + +// @ts-check + +import React from "react"; +import { screen, within } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import BootConfigField from "~/components/storage/BootConfigField"; + +const sda = { + sid: 59, + description: "A fake disk for testing", + isDrive: true, + type: "disk", + vendor: "Micron", + model: "Micron 1100 SATA", + driver: ["ahci", "mmcblk"], + bus: "IDE", + busId: "", + transport: "usb", + dellBOSS: false, + sdCard: true, + active: true, + name: "/dev/sda", + size: 1024, + recoverableSize: 0, + systems : [], + udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], + udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], +}; + +let props; + +beforeEach(() => { + props = { + configureBoot: false, + bootDevice: undefined, + defaultBootDevice: undefined, + devices: [sda], + isLoading: false, + onChange: jest.fn() + }; +}); + +/** + * Helper function that implicitly test that field provides a button for + * opening the dialog + */ +const openBootConfigDialog = async () => { + const { user } = plainRender(); + const button = screen.getByRole("button"); + await user.click(button); + const dialog = screen.getByRole("dialog", { name: "Partitions for booting" }); + + return { user, dialog }; +}; + +describe("BootConfigField", () => { + it("triggers onChange callback when user confirms the dialog", async () => { + const { user, dialog } = await openBootConfigDialog(); + const button = within(dialog).getByRole("button", { name: "Confirm" }); + await user.click(button); + expect(props.onChange).toHaveBeenCalled(); + }); + + it("does not trigger onChange callback when user cancels the dialog", async () => { + const { user, dialog } = await openBootConfigDialog(); + const button = within(dialog).getByRole("button", { name: "Cancel" }); + await user.click(button); + expect(props.onChange).not.toHaveBeenCalled(); + }); + + describe("when installation is set for not configuring boot", () => { + it("renders a text warning about it", () => { + plainRender(); + screen.getByText(/will not configure partitions/); + }); + }); + + describe("when installation is set for automatically configuring boot", () => { + it("renders a text reporting about it", () => { + plainRender(); + screen.getByText(/configure partitions for booting at the installation disk/); + }); + }); + + describe("when installation is set for configuring boot at specific device", () => { + it("renders a text reporting about it", () => { + plainRender(); + screen.getByText(/partitions for booting at \/dev\/sda/); + }); + }); +}); diff --git a/web/src/components/storage/EncryptionField.jsx b/web/src/components/storage/EncryptionField.jsx new file mode 100644 index 0000000000..ae3844af8a --- /dev/null +++ b/web/src/components/storage/EncryptionField.jsx @@ -0,0 +1,115 @@ +/* + * 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 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. + */ + +// @ts-check + +import React, { useCallback, useEffect, useState } from "react"; +import { Skeleton } from "@patternfly/react-core"; +import { _ } from "~/i18n"; +import { noop } from "~/utils"; +import { If, SettingsField } from "~/components/core"; +import { EncryptionMethods } from "~/client/storage"; +import EncryptionSettingsDialog from "~/components/storage/EncryptionSettingsDialog"; + +/** + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + */ + +// Field texts at root level to avoid redefinitions every time the component +// is rendered. +const LABEL = _("Encryption"); +const DESCRIPTION = _("Full disk encryption allows to protect the information stored at \ +the device, including data, programs, and system files."); +const VALUES = { + loading: , + disabled: _("disabled"), + [EncryptionMethods.LUKS2]: _("enabled"), + [EncryptionMethods.TPM]: _("using TPM unlocking") +}; + +/** + * Allows to define encryption + * @component + * + * @typedef {object} EncryptionConfig + * @property {string} password + * @property {string} [method] + * + * @typedef {object} EncryptionFieldProps + * @property {string} [password=""] - Password for encryption + * @property {string} [method=""] - Encryption method + * @property {string[]} [methods=[]] - Possible encryption methods + * @property {boolean} [isLoading=false] - Whether to show the selector as loading + * @property {(config: EncryptionConfig) => void} [onChange=noop] - On change callback + * + * @param {EncryptionFieldProps} props + */ +export default function EncryptionField({ + password = "", + method = "", + // FIXME: should be available methods actually a prop? + methods = [], + isLoading = false, + onChange = noop +}) { + const validPassword = useCallback(() => password?.length > 0, [password]); + const [isEnabled, setIsEnabled] = useState(validPassword()); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + useEffect(() => { + setIsEnabled(validPassword()); + }, [password, validPassword]); + + const openDialog = () => setIsDialogOpen(true); + + const closeDialog = () => setIsDialogOpen(false); + + /** + * @param {import("~/components/storage/EncryptionSettingsDialog").EncryptionSetting} encryptionSetting + */ + const onAccept = (encryptionSetting) => { + closeDialog(); + onChange(encryptionSetting); + }; + + return ( + + + } + /> + + ); +} diff --git a/web/src/components/storage/EncryptionField.test.jsx b/web/src/components/storage/EncryptionField.test.jsx new file mode 100644 index 0000000000..bb216e7e67 --- /dev/null +++ b/web/src/components/storage/EncryptionField.test.jsx @@ -0,0 +1,52 @@ +/* + * 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 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. + */ + +// @ts-check + +import React from "react"; +import { screen, within } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { EncryptionMethods } from "~/client/storage"; +import EncryptionField from "~/components/storage/EncryptionField"; + +describe("EncryptionField", () => { + it("renders proper value depending on encryption status", () => { + // No encryption set + const { rerender } = plainRender(); + screen.getByText("disabled"); + + // Encryption set with LUKS2 + rerender(); + screen.getByText("enabled"); + + // Encryption set with TPM + rerender(); + screen.getByText("using TPM unlocking"); + }); + + it("allows opening the encryption settings dialog", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: /Encryption/ }); + await user.click(button); + const dialog = await screen.findByRole("dialog"); + within(dialog).getByRole("heading", { name: "Encryption" }); + }); +}); diff --git a/web/src/components/storage/EncryptionSettingsDialog.jsx b/web/src/components/storage/EncryptionSettingsDialog.jsx new file mode 100644 index 0000000000..1597a13993 --- /dev/null +++ b/web/src/components/storage/EncryptionSettingsDialog.jsx @@ -0,0 +1,132 @@ +/* + * 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 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. + */ + +// @ts-check + +import React, { useEffect, useState } from "react"; +import { Checkbox, Form } from "@patternfly/react-core"; +import { _ } from "~/i18n"; +import { If, SwitchField, PasswordAndConfirmationInput, Popup } from "~/components/core"; +import { EncryptionMethods } from "~/client/storage"; + +/** + * @typedef {object} EncryptionSetting + * @property {string} password + * @property {string} [method] + */ + +const DIALOG_TITLE = _("Encryption"); +const DIALOG_DESCRIPTION = _("Full disk encryption allows to protect the information stored at \ +the device, including data, programs, and system files."); +const TPM_LABEL = _("Use the TPM to decrypt automatically on each boot"); +// TRANSLATORS: The word 'directly' is key here. For example, booting to the installer media and then choosing +// 'Boot from Hard Disk' from there will not work. Keep it sort (this is a hint in a form) but keep it clear. +// Do not translate 'abbr' and 'title', they are part of the HTML markup. +const TPM_EXPLANATION = _("The password will not be needed to boot and access the data if the \ +TPM can verify the integrity of the system. \ +TPM sealing requires the new system to be booted directly on its first run."); + +/** + * Renders a dialog that allows the user change encryption settings + * @component + * + * @typedef {object} EncryptionSettingsDialogProps + * @property {string} password - Password for encryption. + * @property {string} method - Encryption method. + * @property {string[]} methods - Possible encryption methods. + * @property {boolean} [isOpen=false] - Whether the dialog is visible or not. + * @property {() => void} onCancel - Callback to trigger when on cancel action. + * @property {(settings: EncryptionSetting) => void} onAccept - Callback to trigger on accept action. + * + * @param {EncryptionSettingsDialogProps} props + */ +export default function EncryptionSettingsDialog({ + password: passwordProp, + method: methodProp, + methods, + isOpen = false, + onCancel, + onAccept +}) { + const [isEnabled, setIsEnabled] = useState(passwordProp?.length > 0); + const [password, setPassword] = useState(passwordProp); + const [method, setMethod] = useState(methodProp); + const [passwordsMatch, setPasswordsMatch] = useState(true); + const [validSettings, setValidSettings] = useState(true); + const formId = "encryptionSettingsForm"; + + useEffect(() => { + setValidSettings(!isEnabled || (password.length > 0 && passwordsMatch)); + }, [isEnabled, password, passwordsMatch]); + + const changePassword = (_, v) => setPassword(v); + const changeMethod = (_, useTPM) => setMethod(useTPM ? EncryptionMethods.TPM : EncryptionMethods.LUKS2); + + const submitSettings = (e) => { + e.preventDefault(); + + if (isEnabled) { + onAccept({ password, method }); + } else { + onAccept({ password: "" }); + } + }; + + return ( + + setIsEnabled(!isEnabled)} + label={_("Encrypt the system")} + textWrapper="span" + > +
+ + } + isChecked={method === EncryptionMethods.TPM} + isDisabled={!isEnabled} + onChange={changeMethod} + /> + } + /> + +
+ + + {_("Accept")} + + + +
+ ); +} diff --git a/web/src/components/storage/EncryptionSettingsDialog.test.jsx b/web/src/components/storage/EncryptionSettingsDialog.test.jsx new file mode 100644 index 0000000000..9dcf0eb66b --- /dev/null +++ b/web/src/components/storage/EncryptionSettingsDialog.test.jsx @@ -0,0 +1,177 @@ +/* + * 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 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. + */ + +// @ts-check + +import React from "react"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { EncryptionMethods } from "~/client/storage"; +import EncryptionSettingsDialog from "~/components/storage/EncryptionSettingsDialog"; + +/** @type {import("~/components/storage/EncryptionSettingsDialog").EncryptionSettingsDialogProps} */ +let props; +const onCancelFn = jest.fn(); +const onAcceptFn = jest.fn(); + +describe("EncryptionSettingsDialog", () => { + beforeEach(() => { + props = { + password: "1234", + method: EncryptionMethods.LUKS2, + methods: Object.values(EncryptionMethods), + isOpen: true, + onCancel: onCancelFn, + onAccept: onAcceptFn + }; + }); + + describe("when password is not set", () => { + beforeEach(() => { + props.password = ""; + }); + + it("allows settings the encryption", async () => { + const { user } = plainRender(); + const switchField = screen.getByRole("switch", { name: "Encrypt the system" }); + const passwordInput = screen.getByLabelText("Password"); + const confirmationInput = screen.getByLabelText("Password confirmation"); + const tpmCheckbox = screen.getByRole("checkbox", { name: /Use the TPM/ }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + + expect(switchField).not.toBeChecked(); + expect(passwordInput).toBeDisabled(); + expect(passwordInput).toBeDisabled(); + expect(tpmCheckbox).toBeDisabled(); + + await user.click(switchField); + + expect(switchField).toBeChecked(); + expect(passwordInput).toBeEnabled(); + expect(passwordInput).toBeEnabled(); + expect(tpmCheckbox).toBeEnabled(); + + await user.type(passwordInput, "2345"); + await user.type(confirmationInput, "2345"); + await user.click(acceptButton); + + expect(props.onAccept).toHaveBeenCalledWith( + expect.objectContaining({ password: "2345" }) + ); + }); + }); + + describe("when password is set", () => { + it("allows changing the encryption", async () => { + const { user } = plainRender(); + const passwordInput = screen.getByLabelText("Password"); + const confirmationInput = screen.getByLabelText("Password confirmation"); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + const tpmCheckbox = screen.getByRole("checkbox", { name: /Use the TPM/ }); + + await user.clear(passwordInput); + await user.type(passwordInput, "9876"); + await user.clear(confirmationInput); + await user.type(confirmationInput, "9876"); + await user.click(tpmCheckbox); + await user.click(acceptButton); + + expect(props.onAccept).toHaveBeenCalledWith( + { password: "9876", method: EncryptionMethods.TPM } + ); + }); + + it("allows unsetting the encryption", async () => { + const { user } = plainRender(); + const switchField = screen.getByRole("switch", { name: "Encrypt the system" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + expect(switchField).toBeChecked(); + await user.click(switchField); + expect(switchField).not.toBeChecked(); + await user.click(acceptButton); + expect(props.onAccept).toHaveBeenCalledWith({ password: "" }); + }); + }); + + describe("when using TPM", () => { + beforeEach(() => { + props.method = EncryptionMethods.TPM; + }); + + it("allows to stop using it", async () => { + const { user } = plainRender(); + const tpmCheckbox = screen.queryByRole("checkbox", { name: /Use the TPM/ }); + const acceptButton = screen.queryByRole("button", { name: "Accept" }); + expect(tpmCheckbox).toBeChecked(); + await user.click(tpmCheckbox); + expect(tpmCheckbox).not.toBeChecked(); + await user.click(acceptButton); + expect(props.onAccept).toHaveBeenCalledWith( + expect.not.objectContaining({ method: EncryptionMethods.TPM }) + ); + }); + }); + + describe("when TPM is not included in given methods", () => { + beforeEach(() => { + props.methods = [EncryptionMethods.LUKS2]; + }); + + it("does not render the TPM checkbox", () => { + plainRender(); + expect(screen.queryByRole("checkbox", { name: /Use the TPM/ })).toBeNull(); + }); + }); + + it("does not allow sending not valid settings", async () => { + const { user } = plainRender(); + const switchField = screen.getByRole("switch", { name: "Encrypt the system" }); + const passwordInput = screen.getByLabelText("Password"); + const confirmationInput = screen.getByLabelText("Password confirmation"); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + + expect(acceptButton).toBeEnabled(); + await user.clear(confirmationInput); + // Now password and passwordConfirmation do not match + expect(acceptButton).toBeDisabled(); + await user.click(switchField); + // But now the user is trying to unset the encryption + expect(acceptButton).toBeEnabled(); + await user.click(switchField); + // Back to a not valid settings state + expect(acceptButton).toBeDisabled(); + await user.clear(passwordInput); + await user.clear(confirmationInput); + // Passwords match... but are empty + expect(acceptButton).toBeDisabled(); + await user.type(passwordInput, "valid"); + await user.type(confirmationInput, "valid"); + // Not empty passwords matching! + expect(acceptButton).toBeEnabled(); + }); + + it("triggers onCancel callback when dialog is discarded", async () => { + const { user } = plainRender(); + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + await user.click(cancelButton); + expect(props.onCancel).toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/storage/InstallationDeviceField.jsx b/web/src/components/storage/InstallationDeviceField.jsx new file mode 100644 index 0000000000..a0ef5c6e35 --- /dev/null +++ b/web/src/components/storage/InstallationDeviceField.jsx @@ -0,0 +1,153 @@ +/* + * 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 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. + */ + +// @ts-check + +import React, { useState } from "react"; +import { Skeleton } from "@patternfly/react-core"; + +import { _ } from "~/i18n"; +import { DeviceSelectionDialog, ProposalPageMenu } from "~/components/storage"; +import { deviceLabel } from '~/components/storage/utils'; +import { If, SettingsField } from "~/components/core"; +import { sprintf } from "sprintf-js"; + +/** + * @typedef {import ("~/client/storage").ProposalTarget} ProposalTarget + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + */ + +/** + * Generates the target value. + * @function + * + * @param {ProposalTarget} target + * @param {StorageDevice} targetDevice + * @param {StorageDevice[]} targetPVDevices + * @returns {string} + */ +const targetValue = (target, targetDevice, targetPVDevices) => { + if (target === "DISK" && targetDevice) return deviceLabel(targetDevice); + if (target === "NEW_LVM_VG" && targetPVDevices.length > 0) { + if (targetPVDevices.length > 1) return _("new LVM volume group"); + + if (targetPVDevices.length === 1) { + // TRANSLATORS: %s is the disk used for the LVM physical volumes (eg. "/dev/sda, 80 GiB) + return sprintf(_("new LVM volume group on %s"), deviceLabel(targetPVDevices[0])); + } + } + + return _("No device selected yet"); +}; + +/** + * Field description. + * @function + * + * @returns {React.ReactElement} + */ +const renderDescription = () => ( + LVM \ +Volume Group for installation.") + }} + /> +); + +const StorageTechSelector = () => { + return ( + + ); +}; + +/** + * Allows to select the installation device. + * @component + * + * @typedef {object} InstallationDeviceFieldProps + * @property {ProposalTarget|undefined} target - Installation target + * @property {StorageDevice|undefined} targetDevice - Target device (for target "DISK"). + * @property {StorageDevice[]} targetPVDevices - Target devices for the LVM volume group (target "NEW_LVM_VG"). + * @property {StorageDevice[]} devices - Available devices for installation. + * @property {boolean} isLoading + * @property {(target: TargetConfig) => void} onChange + * + * @typedef {object} TargetConfig + * @property {ProposalTarget} target + * @property {StorageDevice|undefined} targetDevice + * @property {StorageDevice[]} targetPVDevices + * + * @param {InstallationDeviceFieldProps} props + */ + +export default function InstallationDeviceField({ + target, + targetDevice, + targetPVDevices, + devices, + isLoading, + onChange +}) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const openDialog = () => setIsDialogOpen(true); + + const closeDialog = () => setIsDialogOpen(false); + + const onAccept = ({ target, targetDevice, targetPVDevices }) => { + closeDialog(); + onChange({ target, targetDevice, targetPVDevices }); + }; + + let value; + if (isLoading || !target) + value = ; + else + value = targetValue(target, targetDevice, targetPVDevices); + + return ( + + {_("Prepare more devices by configuring advanced")} + + } + /> + + ); +} diff --git a/web/src/components/storage/InstallationDeviceField.test.jsx b/web/src/components/storage/InstallationDeviceField.test.jsx new file mode 100644 index 0000000000..25b8d8c717 --- /dev/null +++ b/web/src/components/storage/InstallationDeviceField.test.jsx @@ -0,0 +1,212 @@ +/* + * 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 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. + */ + +// @ts-check + +import React from "react"; +import { screen, within } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import InstallationDeviceField from "~/components/storage/InstallationDeviceField"; + +/** + * @typedef {import ("~/components/storage/InstallationDeviceField").InstallationDeviceFieldProps} InstallationDeviceFieldProps + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + */ + +/** @type {StorageDevice} */ +const sda = { + sid: 59, + isDrive: true, + type: "disk", + description: "", + vendor: "Micron", + model: "Micron 1100 SATA", + driver: ["ahci", "mmcblk"], + bus: "IDE", + busId: "", + transport: "usb", + dellBOSS: false, + sdCard: true, + active: true, + name: "/dev/sda", + size: 1024, + recoverableSize: 0, + systems : [], + udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], + udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], +}; + +/** @type {StorageDevice} */ +const sdb = { + sid: 62, + isDrive: true, + type: "disk", + description: "", + vendor: "Samsung", + model: "Samsung Evo 8 Pro", + driver: ["ahci"], + bus: "IDE", + busId: "", + transport: "", + dellBOSS: false, + sdCard: false, + active: true, + name: "/dev/sdb", + size: 2048, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: ["pci-0000:00-19"] +}; + +/** @type {InstallationDeviceFieldProps} */ +let props; + +beforeEach(() => { + props = { + target: "DISK", + targetDevice: sda, + targetPVDevices: [], + devices: [sda, sdb], + isLoading: false, + onChange: jest.fn() + }; +}); + +describe("when set as loading", () => { + beforeEach(() => { + props.isLoading = true; + }); + + it("renders a loading hint", () => { + installerRender(); + screen.getByText("Waiting for information about selected device"); + }); +}); + +describe("when the target is a disk", () => { + beforeEach(() => { + props.target = "DISK"; + }); + + describe("and installation device is not selected yet", () => { + beforeEach(() => { + props.targetDevice = undefined; + }); + + it("uses a 'No device selected yet' text for the selection button", async () => { + installerRender(); + screen.getByText("No device selected yet"); + }); + }); + + describe("and an installation device is selected", () => { + beforeEach(() => { + props.targetDevice = sda; + }); + + it("uses its name as part of the text for the selection button", async () => { + installerRender(); + screen.getByText(/\/dev\/sda/); + }); + }); +}); + +describe("when the target is a new LVM volume group", () => { + beforeEach(() => { + props.target = "NEW_LVM_VG"; + }); + + describe("and the target devices are not selected yet", () => { + beforeEach(() => { + props.targetPVDevices = []; + }); + + it("uses a 'No device selected yet' text for the selection button", async () => { + installerRender(); + screen.getByText("No device selected yet"); + }); + }); + + describe("and there is a selected device", () => { + beforeEach(() => { + props.targetPVDevices = [sda]; + }); + + it("uses its name as part of the text for the selection button", async () => { + installerRender(); + screen.getByText(/new LVM .* \/dev\/sda/); + }); + }); + + describe("and there are more than one selected device", () => { + beforeEach(() => { + props.targetPVDevices = [sda, sdb]; + }); + + it("does not use the names as part of the text for the selection button", async () => { + installerRender(); + screen.getByText("new LVM volume group"); + }); + }); +}); + +it("allows changing the selected device", async () => { + const { user } = installerRender(); + const button = screen.getByRole("button", { name: /installation device/i }); + + await user.click(button); + + const selector = await screen.findByRole("dialog", { name: /Device for installing/ }); + const diskGrid = within(selector).getByRole("grid", { name: /target disk/ }); + const sdbRow = within(diskGrid).getByRole("row", { name: /sdb/ }); + const sdbOption = within(sdbRow).getByRole("radio"); + const accept = within(selector).getByRole("button", { name: "Confirm" }); + + await user.click(sdbOption); + await user.click(accept); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(props.onChange).toHaveBeenCalledWith({ + target: "DISK", + targetDevice: sdb, + targetPVDevices: [] + }); +}); + +it("allows canceling a device selection", async () => { + const { user } = installerRender(); + const button = screen.getByRole("button", { name: /installation device/i }); + + await user.click(button); + + const selector = await screen.findByRole("dialog", { name: /Device for installing/ }); + const diskGrid = within(selector).getByRole("grid", { name: /target disk/ }); + const sdbRow = within(diskGrid).getByRole("row", { name: /sdb/ }); + const sdbOption = within(sdbRow).getByRole("radio"); + const cancel = within(selector).getByRole("button", { name: "Cancel" }); + + await user.click(sdbOption); + await user.click(cancel); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(props.onChange).not.toHaveBeenCalled(); +}); diff --git a/web/src/components/storage/ProposalVolumes.jsx b/web/src/components/storage/PartitionsField.jsx similarity index 52% rename from web/src/components/storage/ProposalVolumes.jsx rename to web/src/components/storage/PartitionsField.jsx index 52b8205541..9f883ad8e5 100644 --- a/web/src/components/storage/ProposalVolumes.jsx +++ b/web/src/components/storage/PartitionsField.jsx @@ -22,21 +22,17 @@ // @ts-check import React, { useState } from "react"; -import { - Dropdown, DropdownItem, DropdownList, - List, ListItem, - MenuToggle, - Skeleton, - Toolbar, ToolbarContent, ToolbarItem -} from '@patternfly/react-core'; +import { Button, List, ListItem, Skeleton } from '@patternfly/react-core'; import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; -import { If, Popup, RowActions, Tip } from '~/components/core'; +import { If, ExpandableField, Popup, RowActions, Tip } from '~/components/core'; import { VolumeForm } from '~/components/storage'; import VolumeLocationDialog from '~/components/storage/VolumeLocationDialog'; import { deviceSize, hasSnapshots, isTransactionalRoot } from '~/components/storage/utils'; +import SnapshotsField from "~/components/storage/SnapshotsField"; +import BootConfigField from "~/components/storage/BootConfigField"; import { noop } from "~/utils"; /** @@ -45,15 +41,139 @@ import { noop } from "~/utils"; * @typedef {import ("~/client/storage").Volume} Volume */ +/** + * @component + * + * @param {object} props + * @param {Volume} props.volume + */ +const SizeText = ({ volume }) => { + let targetSize; + if (volume.target === "FILESYSTEM" || volume.target === "DEVICE") + targetSize = volume.targetDevice.size; + + const minSize = deviceSize(targetSize || volume.minSize); + const maxSize = targetSize ? deviceSize(targetSize) : volume.maxSize ? deviceSize(volume.maxSize) : undefined; + + if (minSize && maxSize && minSize !== maxSize) return `${minSize} - ${maxSize}`; + // TRANSLATORS: minimum device size, %s is replaced by size string, e.g. "17.5 GiB" + if (maxSize === undefined) return sprintf(_("at least %s"), minSize); + + return `${minSize}`; +}; + +/** + * @component + * + * @param {object} props + * @param {Volume} props.volume + * @param {ProposalTarget} props.target + */ +const BasicVolumeText = ({ volume, target }) => { + const snapshots = hasSnapshots(volume); + const transactional = isTransactionalRoot(volume); + const size = SizeText({ volume }); + const lvm = (target === "NEW_LVM_VG"); + // When target is "filesystem" or "device" this is irrelevant since the type of device + // is not mentioned + const lv = volume.target === "NEW_VG" || (volume.target === "DEFAULT" && lvm); + + if (transactional) + return (lv) + // TRANSLATORS: "/" is in an LVM logical volume. %s replaced by size string, e.g. "17.5 GiB" + ? sprintf(_("Transactional Btrfs root volume (%s)"), size) + // TRANSLATORS: %s replaced by size string, e.g. "17.5 GiB" + : sprintf(_("Transactional Btrfs root partition (%s)"), size); + + if (snapshots) + return (lv) + // TRANSLATORS: "/" is in an LVM logical volume. %s replaced by size string, e.g. "17.5 GiB" + ? sprintf(_("Btrfs root volume with snapshots (%s)"), size) + // TRANSLATORS: %s replaced by size string, e.g. "17.5 GiB" + : sprintf(_("Btrfs root partition with snapshots (%s)"), size); + + const volTarget = volume.target; + const mount = volume.mountPath; + const device = volume.targetDevice?.name; + + if (volTarget === "FILESYSTEM") + // TRANSLATORS: This results in something like "Mount /dev/sda3 at /home (25 GiB)" since + // %1$s is replaced by the device name, %2$s by the mount point and %3$s by the size + return sprintf(_("Mount %1$s at %2$s (%3$s)"), device, mount, size); + + if (mount === "swap") { + if (volTarget === "DEVICE") + // TRANSLATORS: This results in something like "Swap at /dev/sda3 (2 GiB)" since + // %1$s is replaced by the device name, and %2$s by the size + return sprintf(_("Swap at %1$s (%2$s)"), device, size); + + return (lv) + // TRANSLATORS: Swap is in an LVM logical volume. %s replaced by size string, e.g. "8 GiB" + ? sprintf(_("Swap volume (%s)"), size) + // TRANSLATORS: %s replaced by size string, e.g. "8 GiB" + : sprintf(_("Swap partition (%s)"), size); + } + + const type = volume.fsType; + + if (mount === "/") { + if (volTarget === "DEVICE") + // TRANSLATORS: This results in something like "Btrfs root at /dev/sda3 (20 GiB)" since + // %1$s is replaced by the filesystem type, %2$s by the device name, and %3$s by the size + return sprintf(_("%1$s root at %2$s (%3$s)"), type, device, size); + + return (lv) + // TRANSLATORS: "/" is in an LVM logical volume. + // Results in something like "Btrfs root volume (at least 20 GiB)" since + // $1$s is replaced by filesystem type and %2$s by size description + ? sprintf(_("%1$s root volume (%2$s)"), type, size) + // TRANSLATORS: Results in something like "Btrfs root partition (at least 20 GiB)" since + // $1$s is replaced by filesystem type and %2$s by size description + : sprintf(_("%1$s root partition (%2$s)"), type, size); + } + + if (volTarget === "DEVICE") + // TRANSLATORS: This results in something like "Ext4 /home at /dev/sda3 (20 GiB)" since + // %1$s is replaced by filesystem type, %2$s by mount point, %3$s by device name and %4$s by size + return sprintf(_("%1$s %2$s at %3$s (%4$s)"), type, mount, device, size); + + return (lv) + // TRANSLATORS: The filesystem is in an LVM logical volume. + // Results in something like "Ext4 /home volume (at least 10 GiB)" since + // %1$s is replaced by the filesystem type, %2$s by the mount point and %3$s by the size description + ? sprintf(_("%1$s %2$s volume (%3$s)"), type, mount, size) + // TRANSLATORS: This results in something like "Ext4 /home partition (at least 10 GiB)" since + // %1$s is replaced by the filesystem type, %2$s by the mount point and %3$s by the size description + : sprintf(_("%1$s %2$s partition (%3$s)"), type, mount, size); +}; + +/** + * @component + * + * @param {object} props + * @param {boolean} props.configure + * @param {StorageDevice} props.device + */ +const BootLabelText = ({ configure, device }) => { + if (!configure) + return _("Do not configure partitions for booting"); + + if (!device) + return _("Boot partitions at installation disk"); + + // TRANSLATORS: %s is the disk used to configure the boot-related partitions (eg. "/dev/sda, 80 GiB) + return sprintf(_("Boot partitions at %s"), device.name); +}; + /** * Generates an hint describing which attributes affect the auto-calculated limits. * If the limits are not affected then it returns `null`. - * @function + * @component * - * @param {object} volume - storage volume object - * @returns {(React.ReactElement|null)} component to display (can be `null`) + * @param {object} props + * @param {Volume} props.volume */ -const AutoCalculatedHint = (volume) => { +const AutoCalculatedHint = ({ volume }) => { const { snapshotsAffectSizes = false, sizeRelevantVolumes = [], adjustByRam } = volume.outline; // no hint, the size is not affected by known criteria @@ -99,7 +219,6 @@ const AutoCalculatedHint = (volume) => { * @return {void} */ const GeneralActions = ({ templates, onAdd, onReset }) => { - const [isOpen, setIsOpen] = useState(false); const [isFormOpen, setIsFormOpen] = useState(false); const openForm = () => setIsFormOpen(true); @@ -111,45 +230,14 @@ const GeneralActions = ({ templates, onAdd, onReset }) => { onAdd(volume); }; - const toggleActions = () => setIsOpen(!isOpen); - - const closeActions = () => setIsOpen(false); - - const Action = ({ children, ...props }) => ( - {children} - ); - return ( - <> - ( - - {/* TRANSLATORS: dropdown label */} - {_("Actions")} - - )} - > - - - {/* TRANSLATORS: dropdown menu label */} - {_("Reset to defaults")} - - - {/* TRANSLATORS: dropdown menu label */} - {_("Add file system")} - - - +
+ + { - +
+ ); +}; + +/** + * @component + * + * @param {object} props + * @param {Volume} props.volume + * @param {ProposalTarget} props.target + */ +const VolumeLabel = ({ volume, target }) => { + return ( +
+ {BasicVolumeText({ volume, target })} +
+ ); +}; + +/** + * @component + * + * @param {object} props + * @param {StorageDevice|undefined} props.bootDevice + * @param {boolean} props.configureBoot + */ +const BootLabel = ({ bootDevice, configureBoot }) => { + return ( +
+ {BootLabelText({ configure: configureBoot, device: bootDevice })} +
); }; @@ -212,24 +330,13 @@ const VolumeRow = ({ * @param {Volume} props.volume */ const SizeLimits = ({ volume }) => { - let targetSize; - if (volume.target === "FILESYSTEM" || volume.target === "DEVICE") - targetSize = volume.targetDevice.size; - - const minSize = deviceSize(targetSize || volume.minSize); - const maxSize = targetSize ? deviceSize(targetSize) : volume.maxSize ? deviceSize(volume.maxSize) : undefined; const isAuto = volume.autoSize; - let size = minSize; - if (minSize && maxSize && minSize !== maxSize) size = `${minSize} - ${maxSize}`; - // TRANSLATORS: minimum device size, %s is replaced by size string, e.g. "17.5 GiB" - if (maxSize === undefined) size = sprintf(_("At least %s"), minSize); - return (
- {size} + {SizeText({ volume })} {/* TRANSLATORS: device flag, the partition size is automatically computed */} - {_("auto")}} /> + {_("auto")}} />
); }; @@ -312,7 +419,7 @@ const VolumeRow = ({ if (isLoading) { return ( - + ); } @@ -437,62 +544,192 @@ const VolumesTable = ({ volumes, devices, target, targetDevice, isLoading, onVol ); }; +/** + * Content to show when the field is collapsed. + * @component + * + * @param {object} props + * @param {Volume[]} props.volumes + * @param {boolean} props.configureBoot + * @param {StorageDevice|undefined} props.bootDevice + * @param {ProposalTarget} props.target + * @param {boolean} props.isLoading + */ +const Basic = ({ volumes, configureBoot, bootDevice, target, isLoading }) => { + if (isLoading) + return ( +
+ + + +
+ ); + + return ( +
+ {volumes.map((v, i) => )} + +
+ ); +}; + +/** + * Content to show when the field is expanded. + * @component + * + * @param {object} props + * @param {Volume[]} props.volumes + * @param {Volume[]} props.templates + * @param {StorageDevice[]} props.devices + * @param {ProposalTarget} props.target + * @param {StorageDevice|undefined} props.targetDevice + * @param {boolean} props.configureBoot + * @param {StorageDevice|undefined} props.bootDevice + * @param {StorageDevice|undefined} props.defaultBootDevice + * @param {(volumes: Volume[]) => void} props.onVolumesChange + * @param {(boot: BootConfig) => void} props.onBootChange + * @param {boolean} props.isLoading + */ +const Advanced = ({ + volumes, + templates, + devices, + target, + targetDevice, + configureBoot, + bootDevice, + defaultBootDevice, + onVolumesChange, + onBootChange, + isLoading +}) => { + const rootVolume = (volumes || []).find((i) => i.mountPath === "/"); + + const addVolume = (volume) => onVolumesChange([...volumes, volume]); + + const resetVolumes = () => onVolumesChange([]); + + const changeBtrfsSnapshots = ({ active }) => { + // const rootVolume = volumes.find((i) => i.mountPath === "/"); + + if (active) { + rootVolume.fsType = "Btrfs"; + rootVolume.snapshots = true; + } else { + rootVolume.snapshots = false; + } + + onVolumesChange(volumes); + }; + + return ( +
+ } + /> + + +
+ +
+ ); +}; + /** * @todo This component should be restructured to use the same approach as other newer components: * * Create dialog components for the popup forms (e.g., EditVolumeDialog). * * Use a TreeTable, specially if we need to represent subvolumes. * - * Renders information of the volumes and actions to modify them + * Renders information of the volumes and boot-related partitions and actions to modify them. * @component * - * @typedef {object} ProposalVolumesProps + * @typedef {object} PartitionsFieldProps * @property {Volume[]} volumes - Volumes to show * @property {Volume[]} templates - Templates to use for new volumes * @property {StorageDevice[]} devices - Devices available for installation * @property {ProposalTarget} target - Installation target * @property {StorageDevice|undefined} targetDevice - Device selected for installation, if target is a disk + * @property {boolean} configureBoot - Whether to configure boot partitions. + * @property {StorageDevice|undefined} bootDevice - Device to use for creating boot partitions. + * @property {StorageDevice|undefined} defaultBootDevice - Default device for boot partitions if no device has been indicated yet. * @property {boolean} [isLoading=false] - Whether to show the content as loading - * @property {(volumes: Volume[]) => void} onChange - Function to use for changing the volumes + * @property {(volumes: Volume[]) => void} onVolumesChange - Function to use for changing the volumes + * @property {(boot: BootConfig) => void} onBootChange - Function for changing the boot settings + * + * @typedef {object} BootConfig + * @property {boolean} configureBoot + * @property {StorageDevice|undefined} bootDevice * - * @param {ProposalVolumesProps} props + * @param {PartitionsFieldProps} props */ -export default function ProposalVolumes({ +export default function PartitionsField({ volumes, templates, devices, target, targetDevice, + configureBoot, + bootDevice, + defaultBootDevice, isLoading = false, - onChange = noop + onVolumesChange, + onBootChange }) { - const addVolume = (volume) => onChange([...volumes, volume]); - - const resetVolumes = () => onChange([]); + const [isExpanded, setIsExpanded] = useState(false); return ( - <> - - - - {_("File systems to create")} - - - - - - - setIsExpanded(!isExpanded)} + > + + } + else={ + + } /> - + ); } diff --git a/web/src/components/storage/ProposalVolumes.test.jsx b/web/src/components/storage/PartitionsField.test.jsx similarity index 76% rename from web/src/components/storage/ProposalVolumes.test.jsx rename to web/src/components/storage/PartitionsField.test.jsx index 34557c521e..1ca64e36e8 100644 --- a/web/src/components/storage/ProposalVolumes.test.jsx +++ b/web/src/components/storage/PartitionsField.test.jsx @@ -24,10 +24,10 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import { ProposalVolumes } from "~/components/storage"; +import PartitionsField from "~/components/storage/PartitionsField"; /** - * @typedef {import ("~/components/storage/ProposalVolumes").ProposalVolumesProps} ProposalVolumesProps + * @typedef {import("~/components/storage/PartitionsField").PartitionsFieldProps} PartitionsFieldProps * @typedef {import ("~/client/storage").StorageDevice} StorageDevice * @typedef {import ("~/client/storage").Volume} Volume */ @@ -38,7 +38,6 @@ jest.mock("@patternfly/react-core", () => { return { ...original, Skeleton: () =>
PFSkeleton
- }; }); @@ -145,79 +144,69 @@ const sda2 = { } }; -/** @type {ProposalVolumesProps} */ +/** @type {PartitionsFieldProps} */ let props; +const expandField = async () => { + const render = plainRender(); + const button = screen.getByRole("button", { name: "Partitions and file systems" }); + await render.user.click(button); + return render; +}; + beforeEach(() => { props = { - volumes: [], + volumes: [rootVolume, swapVolume], templates: [], devices: [], target: "DISK", targetDevice: undefined, - onChange: jest.fn() + configureBoot: false, + bootDevice: undefined, + defaultBootDevice: undefined, + onVolumesChange: jest.fn(), + onBootChange: jest.fn() }; }); -it("renders a button for the generic actions", async () => { - const { user } = plainRender(); +/** @todo Add tests for collapsed field. */ - const button = screen.getByRole("button", { name: "Actions" }); +it("allows to reset the file systems", async () => { + const { user } = await expandField(); + const button = screen.getByRole("button", { name: "Reset to defaults" }); await user.click(button); - const menu = screen.getByRole("menu"); - within(menu).getByRole("menuitem", { name: /Reset/ }); - within(menu).getByRole("menuitem", { name: /Add/ }); + expect(props.onVolumesChange).toHaveBeenCalledWith([]); }); -it("changes the volumes if reset action is used", async () => { - const { user } = plainRender(); - - const button = screen.getByRole("button", { name: "Actions" }); - await user.click(button); - const menu = screen.getByRole("menu"); - const reset = within(menu).getByRole("menuitem", { name: /Reset/ }); - await user.click(reset); - - expect(props.onChange).toHaveBeenCalledWith([]); -}); - -it("allows to add a volume if add action is used", async () => { +it("allows to add a file system", async () => { props.templates = [homeVolume]; + const { user } = await expandField(); - const { user } = plainRender(); - - const button = screen.getByRole("button", { name: "Actions" }); + const button = screen.getByRole("button", { name: "Add file system" }); await user.click(button); - const menu = screen.getByRole("menu"); - const add = within(menu).getByRole("menuitem", { name: /Add/ }); - await user.click(add); const popup = await screen.findByRole("dialog"); const accept = within(popup).getByRole("button", { name: "Accept" }); await user.click(accept); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(props.onChange).toHaveBeenCalledWith([props.templates[0]]); + expect(props.onVolumesChange).toHaveBeenCalledWith([rootVolume, swapVolume, homeVolume]); }); -it("allows to cancel if add action is used", async () => { +it("allows to cancel adding a file system", async () => { props.templates = [homeVolume]; + const { user } = await expandField(); - const { user } = plainRender(); - - const button = screen.getByRole("button", { name: "Actions" }); + const button = screen.getByRole("button", { name: "Add file system" }); await user.click(button); - const menu = screen.getByRole("menu"); - const add = within(menu).getByRole("menuitem", { name: /Add/ }); - await user.click(add); const popup = await screen.findByRole("dialog"); const cancel = within(popup).getByRole("button", { name: "Cancel" }); await user.click(cancel); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(props.onChange).not.toHaveBeenCalled(); + expect(props.onVolumesChange).not.toHaveBeenCalled(); }); describe("if there are volumes", () => { @@ -227,8 +216,7 @@ describe("if there are volumes", () => { it("renders skeleton for each volume if loading", async () => { props.isLoading = true; - - plainRender(); + await expandField(); const [, body] = await screen.findAllByRole("rowgroup"); @@ -240,34 +228,34 @@ describe("if there are volumes", () => { }); it("renders the information for each volume", async () => { - plainRender(); + await expandField(); const [, body] = await screen.findAllByRole("rowgroup"); expect(within(body).queryAllByRole("row").length).toEqual(3); within(body).getByRole("row", { name: "/ Btrfs 1 KiB - 2 KiB Partition at installation disk" }); - within(body).getByRole("row", { name: "/home XFS At least 1 KiB Partition at installation disk" }); + within(body).getByRole("row", { name: "/home XFS at least 1 KiB Partition at installation disk" }); within(body).getByRole("row", { name: "swap Swap 1 KiB Partition at installation disk" }); }); it("allows deleting the volume", async () => { - const { user } = plainRender(); + const { user } = await expandField(); const [, body] = await screen.findAllByRole("rowgroup"); - const row = within(body).getByRole("row", { name: "/home XFS At least 1 KiB Partition at installation disk" }); + const row = within(body).getByRole("row", { name: "/home XFS at least 1 KiB Partition at installation disk" }); const actions = within(row).getByRole("button", { name: "Actions" }); await user.click(actions); const deleteAction = within(row).queryByRole("menuitem", { name: "Delete" }); await user.click(deleteAction); - expect(props.onChange).toHaveBeenCalledWith(expect.not.arrayContaining([homeVolume])); + expect(props.onVolumesChange).toHaveBeenCalledWith(expect.not.arrayContaining([homeVolume])); }); it("allows editing the volume", async () => { - const { user } = plainRender(); + const { user } = await expandField(); const [, body] = await screen.findAllByRole("rowgroup"); - const row = within(body).getByRole("row", { name: "/home XFS At least 1 KiB Partition at installation disk" }); + const row = within(body).getByRole("row", { name: "/home XFS at least 1 KiB Partition at installation disk" }); const actions = within(row).getByRole("button", { name: "Actions" }); await user.click(actions); const editAction = within(row).queryByRole("menuitem", { name: "Edit" }); @@ -278,10 +266,10 @@ describe("if there are volumes", () => { }); it("allows changing the location of the volume", async () => { - const { user } = plainRender(); + const { user } = await expandField(); const [, body] = await screen.findAllByRole("rowgroup"); - const row = within(body).getByRole("row", { name: "/home XFS At least 1 KiB Partition at installation disk" }); + const row = within(body).getByRole("row", { name: "/home XFS at least 1 KiB Partition at installation disk" }); const actions = within(row).getByRole("button", { name: "Actions" }); await user.click(actions); const locationAction = within(row).queryByRole("menuitem", { name: "Change location" }); @@ -297,7 +285,7 @@ describe("if there are volumes", () => { }); it("renders 'transactional' legend as part of its information", async () => { - plainRender(); + await expandField(); const [, volumes] = await screen.findAllByRole("rowgroup"); @@ -311,7 +299,7 @@ describe("if there are volumes", () => { }); it("renders 'with snapshots' legend as part of its information", async () => { - plainRender(); + await expandField(); const [, volumes] = await screen.findAllByRole("rowgroup"); @@ -329,12 +317,12 @@ describe("if there are volumes", () => { }); it("renders the locations", async () => { - plainRender(); + await expandField(); const [, volumes] = await screen.findAllByRole("rowgroup"); within(volumes).getByRole("row", { name: "swap Swap 1 KiB Partition at /dev/sda" }); - within(volumes).getByRole("row", { name: "/home XFS At least 1 KiB Separate LVM at /dev/sda" }); + within(volumes).getByRole("row", { name: "/home XFS at least 1 KiB Separate LVM at /dev/sda" }); }); }); @@ -348,7 +336,7 @@ describe("if there are volumes", () => { }); it("renders the locations", async () => { - plainRender(); + await expandField(); const [, volumes] = await screen.findAllByRole("rowgroup"); @@ -364,7 +352,7 @@ describe("if there are not volumes", () => { }); it("renders an empty table if it is not loading", async () => { - plainRender(); + await expandField(); const [, body] = await screen.findAllByRole("rowgroup"); expect(body).toBeEmptyDOMElement(); @@ -373,7 +361,7 @@ describe("if there are not volumes", () => { it("renders an skeleton row if it is loading", async () => { props.isLoading = true; - plainRender(); + await expandField(); const [, body] = await screen.findAllByRole("rowgroup"); const rows = within(body).getAllByRole("row", { name: "PFSkeleton" }); diff --git a/web/src/components/storage/ProposalDeviceSection.jsx b/web/src/components/storage/ProposalDeviceSection.jsx deleted file mode 100644 index a076529db2..0000000000 --- a/web/src/components/storage/ProposalDeviceSection.jsx +++ /dev/null @@ -1,198 +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 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. - */ - -// @ts-check - -import React, { useState } from "react"; -import { - Button, - Skeleton, -} from "@patternfly/react-core"; - -import { _ } from "~/i18n"; -import { DeviceSelectionDialog } from "~/components/storage"; -import { deviceLabel } from '~/components/storage/utils'; -import { If, Section } from "~/components/core"; -import { sprintf } from "sprintf-js"; -import { compact, noop } from "~/utils"; - -/** - * @typedef {import ("~/client/storage").ProposalTarget} ProposalTarget - * @typedef {import ("~/client/storage").ProposalSettings} ProposalSettings - * @typedef {import ("~/client/storage").StorageDevice} StorageDevice - */ - -/** - * Renders a button that allows changing the target device for installation. - * - * @param {object} props - * @param {ProposalTarget} props.target - * @param {StorageDevice|undefined} props.targetDevice - * @param {StorageDevice[]} props.targetPVDevices - * @param {import("react").MouseEventHandler} [props.onClick=noop] - */ -const TargetDeviceButton = ({ target, targetDevice, targetPVDevices, onClick = noop }) => { - const label = () => { - if (target === "DISK" && targetDevice) return deviceLabel(targetDevice); - if (target === "NEW_LVM_VG" && targetPVDevices.length > 0) { - if (targetPVDevices.length > 1) return _("new LVM volume group"); - - if (targetPVDevices.length === 1) { - // TRANSLATORS: %s is the disk used for the LVM physical volumes (eg. "/dev/sda, 80 GiB) - return sprintf(_("new LVM volume group on %s"), deviceLabel(targetPVDevices[0])); - } - } - - return _("No device selected yet"); - }; - - return ( - - ); -}; - -/** - * Allows to select the installation device. - * @component - * - * @param {object} props - * @param {ProposalTarget} props.target - Installation target - * @param {StorageDevice|undefined} props.targetDevice - Target device (for target "DISK"). - * @param {StorageDevice[]} props.targetPVDevices - Target devices for the LVM volume group (target "NEW_LVM_VG"). - * @param {StorageDevice[]} props.devices - Available devices for installation. - * @param {boolean} props.isLoading - * @param {(target: Target) => void} props.onChange - * - * @typedef {object} Target - * @property {ProposalTarget} target - * @property {StorageDevice|undefined} targetDevice - * @property {StorageDevice[]} targetPVDevices - */ -const InstallationDeviceField = ({ - target, - targetDevice, - targetPVDevices, - devices, - isLoading, - onChange -}) => { - const [isDialogOpen, setIsDialogOpen] = useState(false); - - const openDialog = () => setIsDialogOpen(true); - - const closeDialog = () => setIsDialogOpen(false); - - const onAccept = ({ target, targetDevice, targetPVDevices }) => { - closeDialog(); - onChange({ target, targetDevice, targetPVDevices }); - }; - - if (isLoading) { - return ; - } - - return ( -
- {_("Installation device")} - - - } - /> -
- ); -}; - -/** - * Section for editing the target device for installation. - * @component - * - * @param {object} props - * @param {ProposalSettings} props.settings - * @param {StorageDevice[]} [props.availableDevices=[]] - * @param {boolean} [props.isLoading=false] - * @param {(settings: object) => void} [props.onChange=noop] - */ -export default function ProposalDeviceSection({ - settings, - availableDevices = [], - isLoading = false, - onChange = noop -}) { - const findDevice = (name) => availableDevices.find(a => a.name === name); - - const target = settings.target; - const targetDevice = findDevice(settings.targetDevice); - const targetPVDevices = compact(settings.targetPVDevices?.map(findDevice) || []); - - const changeTarget = ({ target, targetDevice, targetPVDevices }) => { - onChange({ - target, - targetDevice: targetDevice?.name, - targetPVDevices: targetPVDevices.map(d => d.name) - }); - }; - - const Description = () => ( - LVM \ -Volume Group for installation.") - }} - /> - ); - - return ( -
} - > - -
- ); -} diff --git a/web/src/components/storage/ProposalDeviceSection.test.jsx b/web/src/components/storage/ProposalDeviceSection.test.jsx deleted file mode 100644 index bdbab62055..0000000000 --- a/web/src/components/storage/ProposalDeviceSection.test.jsx +++ /dev/null @@ -1,233 +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 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. - */ - -// @ts-check - -import React from "react"; -import { screen, within } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import { ProposalDeviceSection } from "~/components/storage"; - -const sda = { - sid: 59, - isDrive: true, - type: "disk", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - busId: "", - transport: "usb", - dellBOSS: false, - sdCard: true, - active: true, - name: "/dev/sda", - size: 1024, - recoverableSize: 0, - systems : [], - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], -}; - -const sdb = { - sid: 62, - isDrive: true, - type: "disk", - vendor: "Samsung", - model: "Samsung Evo 8 Pro", - driver: ["ahci"], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sdb", - size: 2048, - recoverableSize: 0, - systems : [], - udevIds: [], - udevPaths: ["pci-0000:00-19"] -}; - -let props; - -describe("ProposalDeviceSection", () => { - beforeEach(() => { - props = { - settings: { - target: "DISK", - targetDevice: "/dev/sda", - }, - availableDevices: [sda, sdb], - isLoading: false, - onChange: jest.fn() - }; - }); - - describe("Installation device field", () => { - describe("when set as loading", () => { - beforeEach(() => { - props.isLoading = true; - }); - - describe("and selected device is not defined yet", () => { - beforeEach(() => { - props.settings.target = undefined; - }); - - it("renders a loading hint", () => { - plainRender(); - screen.getByText("Waiting for information about selected device"); - }); - }); - }); - - describe("when the target is a disk", () => { - beforeEach(() => { - props.settings.target = "DISK"; - }); - - describe("and installation device is not selected yet", () => { - beforeEach(() => { - props.settings.targetDevice = ""; - }); - - it("uses a 'No device selected yet' text for the selection button", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: "No device selected yet" }); - - await user.click(button); - - screen.getByRole("dialog", { name: /Device for installing/i }); - }); - }); - - describe("and an installation device is selected", () => { - beforeEach(() => { - props.settings.targetDevice = "/dev/sda"; - }); - - it("uses its name as part of the text for the selection button", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: /\/dev\/sda/ }); - - await user.click(button); - - screen.getByRole("dialog", { name: /Device for installing/i }); - }); - }); - }); - - describe("when the target is a new LVM volume group", () => { - beforeEach(() => { - props.settings.target = "NEW_LVM_VG"; - }); - - describe("and the target devices are not selected yet", () => { - beforeEach(() => { - props.settings.targetPVDevices = []; - }); - - it("uses a 'No device selected yet' text for the selection button", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: "No device selected yet" }); - - await user.click(button); - - screen.getByRole("dialog", { name: /Device for installing/i }); - }); - }); - - describe("and there is a selected device", () => { - beforeEach(() => { - props.settings.targetPVDevices = ["/dev/sda"]; - }); - - it("uses its name as part of the text for the selection button", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: /new LVM .* \/dev\/sda/ }); - - await user.click(button); - - screen.getByRole("dialog", { name: /Device for installing/i }); - }); - }); - - describe("and there are more than one selected device", () => { - beforeEach(() => { - props.settings.targetPVDevices = ["/dev/sda", "/dev/sdb"]; - }); - - it("does not use the names as part of the text for the selection button", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: "new LVM volume group" }); - - await user.click(button); - - screen.getByRole("dialog", { name: /Device for installing/i }); - }); - }); - }); - - it("allows changing the selected device", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: "/dev/sda, 1 KiB" }); - - await user.click(button); - - const selector = await screen.findByRole("dialog", { name: /Device for installing/ }); - const diskGrid = within(selector).getByRole("grid", { name: /target disk/ }); - const sdbRow = within(diskGrid).getByRole("row", { name: /sdb/ }); - const sdbOption = within(sdbRow).getByRole("radio"); - const accept = within(selector).getByRole("button", { name: "Confirm" }); - - await user.click(sdbOption); - await user.click(accept); - - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(props.onChange).toHaveBeenCalledWith({ - target: "DISK", - targetDevice: sdb.name, - targetPVDevices: [] - }); - }); - - it("allows canceling a device selection", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: "/dev/sda, 1 KiB" }); - - await user.click(button); - - const selector = await screen.findByRole("dialog", { name: /Device for installing/ }); - const diskGrid = within(selector).getByRole("grid", { name: /target disk/ }); - const sdbRow = within(diskGrid).getByRole("row", { name: /sdb/ }); - const sdbOption = within(sdbRow).getByRole("radio"); - const cancel = within(selector).getByRole("button", { name: "Cancel" }); - - await user.click(sdbOption); - await user.click(cancel); - - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(props.onChange).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/web/src/components/storage/ProposalPage.jsx b/web/src/components/storage/ProposalPage.jsx index 6123835fae..b2c4169566 100644 --- a/web/src/components/storage/ProposalPage.jsx +++ b/web/src/components/storage/ProposalPage.jsx @@ -27,7 +27,6 @@ import { toValidationError, useCancellablePromise } from "~/utils"; import { Page } from "~/components/core"; import { ProposalPageMenu, - ProposalDeviceSection, ProposalTransactionalInfo, ProposalSettingsSection, ProposalResultSection @@ -230,12 +229,6 @@ export default function ProposalPage() { - ({ }) })); +const createClientMock = /** @type {jest.Mock} */(createClient); + /** @type {StorageDevice} */ const vda = { sid: 59, @@ -168,8 +170,7 @@ beforeEach(() => { onStatusChange: jest.fn() }; - // @ts-expect-error Mocking method does not exist fo InstallerClient type. - createClient.mockImplementation(() => ({ storage })); + createClientMock.mockImplementation(() => ({ storage })); }); it("probes storage if the storage devices are deprecated", async () => { @@ -189,9 +190,6 @@ it("loads the proposal data", async () => { installerRender(); - screen.getAllByText(/PFSkeleton/); - expect(screen.queryByText(/Installation device/)).toBeNull(); - await screen.findByText(/Installation device/); await screen.findByText(/\/dev\/vda/); }); @@ -238,15 +236,6 @@ describe("when the storage devices become deprecated", () => { }); describe("when there is no proposal yet", () => { - it("shows the page as loading", async () => { - proposalResult = undefined; - - installerRender(); - - screen.getAllByText(/PFSkeleton/); - await waitFor(() => expect(screen.queryByText(/Installation device/)).toBeNull()); - }); - it("loads the proposal when the service finishes to calculate", async () => { const defaultResult = proposalResult; proposalResult = undefined; diff --git a/web/src/components/storage/ProposalPageMenu.jsx b/web/src/components/storage/ProposalPageMenu.jsx index 04f97a36e5..6c5590f61a 100644 --- a/web/src/components/storage/ProposalPageMenu.jsx +++ b/web/src/components/storage/ProposalPageMenu.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -19,6 +19,8 @@ * find current contact information at www.suse.com. */ +// @ts-check + import React, { useEffect, useState } from "react"; import { useHref } from "react-router-dom"; @@ -83,8 +85,13 @@ const ISCSILink = () => { /** * Component for rendering the options available from Storage/ProposalPage * @component + * + * @typedef {object} ProposalMenuProps + * @property {string} label + * + * @param {ProposalMenuProps} props */ -export default function ProposalPageMenu () { +export default function ProposalPageMenu ({ label }) { const [showDasdLink, setShowDasdLink] = useState(false); const [showZFCPLink, setShowZFCPLink] = useState(false); const { storage: client } = useInstallerClient(); @@ -95,7 +102,7 @@ export default function ProposalPageMenu () { }, [client.dasd, client.zfcp]); return ( - + } /> diff --git a/web/src/components/storage/ProposalSettingsSection.jsx b/web/src/components/storage/ProposalSettingsSection.jsx index 8d9fc4f253..04948c5df7 100644 --- a/web/src/components/storage/ProposalSettingsSection.jsx +++ b/web/src/components/storage/ProposalSettingsSection.jsx @@ -21,388 +21,25 @@ // @ts-check -import React, { useEffect, useState } from "react"; -import { Button, Checkbox, Form, Skeleton, Switch, Tooltip } from "@patternfly/react-core"; - -import { sprintf } from "sprintf-js"; -import { _, n_ } from "~/i18n"; -import { BootSelectionDialog, ProposalVolumes, SpacePolicyDialog } from "~/components/storage"; -import { If, PasswordAndConfirmationInput, Section, Popup } from "~/components/core"; -import { Icon } from "~/components/layout"; -import { noop } from "~/utils"; -import { hasFS, deviceLabel, SPACE_POLICIES } from "~/components/storage/utils"; +import React from "react"; +import { _ } from "~/i18n"; +import { compact } from "~/utils"; +import { Section } from "~/components/core"; +import { SPACE_POLICIES } from "~/components/storage/utils"; +import EncryptionField from "~/components/storage/EncryptionField"; +import InstallationDeviceField from "~/components/storage/InstallationDeviceField"; +import PartitionsField from "~/components/storage/PartitionsField"; +import SpacePolicyField from "~/components/storage/SpacePolicyField"; /** * @typedef {import ("~/client/storage").ProposalSettings} ProposalSettings + * @typedef {import ("~/client/storage").ProposalTarget} ProposalTarget * @typedef {import ("~/client/storage").SpaceAction} SpaceAction * @typedef {import ("~/components/storage/utils").SpacePolicy} SpacePolicy * @typedef {import ("~/client/storage").StorageDevice} StorageDevice * @typedef {import ("~/client/storage").Volume} Volume */ -/** - * Form for configuring the encryption password. - * @component - * - * @param {object} props - * @param {string} props.id - Form ID. - * @param {string} props.password - Password for encryption. - * @param {string} props.method - Encryption method. - * @param {string[]} props.methods - Possible encryption methods. - * @param {(password: string, method: string) => void} [props.onSubmit=noop] - On submit callback. - * @param {(valid: boolean) => void} [props.onValidate=noop] - On validate callback. - */ -const EncryptionSettingsForm = ({ - id, - password: passwordProp, - method: methodProp, - methods, - onSubmit = noop, - onValidate = noop -}) => { - const [password, setPassword] = useState(passwordProp || ""); - const [method, setMethod] = useState(methodProp); - const tpmId = "tpm_fde"; - const luks2Id = "luks2"; - - useEffect(() => { - if (password.length === 0) onValidate(false); - }, [password, onValidate]); - - const changePassword = (_, v) => setPassword(v); - - const changeMethod = (_, value) => { - value ? setMethod(tpmId) : setMethod(luks2Id); - }; - - const submitForm = (e) => { - e.preventDefault(); - onSubmit(password, method); - }; - - const Description = () => ( - TPM can verify the integrity of the system. TPM sealing requires the new system to be booted directly on its first run.") - }} - /> - ); - - return ( -
- - } - isChecked={method === tpmId} - onChange={changeMethod} - /> - } - /> - - ); -}; - -/** - * Allows to define snapshots enablement - * @component - * - * @param {object} props - * @param {ProposalSettings} props.settings - Settings used for calculating a proposal. - * @param {(config: SnapshotsConfig) => void} [props.onChange=noop] - On change callback - * - * @typedef {object} SnapshotsConfig - * @property {boolean} active - * @property {ProposalSettings} settings - */ -const SnapshotsField = ({ - settings, - onChange = noop -}) => { - const rootVolume = (settings.volumes || []).find((i) => i.mountPath === "/"); - - // no root volume is probably some error or still loading - if (rootVolume === undefined) { - return ; - } - - const isChecked = rootVolume !== undefined && hasFS(rootVolume, "Btrfs") && rootVolume.snapshots; - - const switchState = (_, checked) => { - onChange({ active: checked, settings }); - }; - - if (!rootVolume.outline.snapshotsConfigurable) return; - - const explanation = _("Uses Btrfs for the root file system allowing to boot to a previous \ -version of the system after configuration changes or software upgrades."); - - return ( -
- -
- {explanation} -
-
- ); -}; - -/** - * Allows to define encryption - * @component - * - * @param {object} props - * @param {string} [props.password=""] - Password for encryption - * @param {string} [props.method=""] - Encryption method - * @param {string[]} [props.methods] - Possible encryption methods - * @param {boolean} [props.isChecked=false] - Whether encryption is selected - * @param {boolean} [props.isLoading=false] - Whether to show the selector as loading - * @param {(config: EncryptionConfig) => void} [props.onChange=noop] - On change callback - * - * @typedef {object} EncryptionConfig - * @property {string} password - * @property {string} [method] - */ -const EncryptionField = ({ - password = "", - method = "", - methods, - isChecked: defaultIsChecked = false, - isLoading = false, - onChange = noop -}) => { - const [isChecked, setIsChecked] = useState(defaultIsChecked); - const [isFormOpen, setIsFormOpen] = useState(false); - const [isFormValid, setIsFormValid] = useState(true); - - const openForm = () => setIsFormOpen(true); - - const closeForm = () => setIsFormOpen(false); - - const acceptForm = (newPassword, newMethod) => { - closeForm(); - onChange({ password: newPassword, method: newMethod }); - }; - - const cancelForm = () => { - setIsChecked(defaultIsChecked); - closeForm(); - }; - - const validateForm = (valid) => setIsFormValid(valid); - - const changeSelected = (_, value) => { - setIsChecked(value); - - if (value && password.length === 0) openForm(); - - if (!value) { - onChange({ password: "" }); - } - }; - - const ChangeSettingsButton = () => { - return ( - - - - ); - }; - - if (isLoading) return ; - - return ( - <> -
- - { isChecked && } -
- - - - {_("Accept")} - - - - - ); -}; - -/** - * Allows to select the boot config. - * @component - * - * @param {object} props - * @param {boolean} props.configureBoot - * @param {StorageDevice|undefined} props.bootDevice - * @param {StorageDevice|undefined} props.defaultBootDevice - * @param {StorageDevice[]} props.devices - * @param {boolean} props.isLoading - * @param {(boot: Boot) => void} props.onChange - * - * @typedef {object} Boot - * @property {boolean} configureBoot - * @property {StorageDevice} bootDevice - */ -const BootConfigField = ({ - configureBoot, - bootDevice, - defaultBootDevice, - devices, - isLoading, - onChange -}) => { - const [isDialogOpen, setIsDialogOpen] = useState(false); - - const openDialog = () => setIsDialogOpen(true); - - const closeDialog = () => setIsDialogOpen(false); - - const onAccept = ({ configureBoot, bootDevice }) => { - closeDialog(); - onChange({ configureBoot, bootDevice }); - }; - - const label = _("Automatically configure any additional partition to boot the system"); - - const value = () => { - if (!configureBoot) return _("nowhere (manual boot setup)"); - - if (!bootDevice) return _("at the installation device"); - - // TRANSLATORS: %s is the disk used to configure the boot-related partitions (eg. "/dev/sda, 80 GiB) - return sprintf(_("at %s"), deviceLabel(bootDevice)); - }; - - if (isLoading) { - return ; - } - - return ( -
- {label} - - - } - /> -
- ); -}; - -/** - * Allows to select the space policy. - * @component - * - * @param {object} props - * @param {SpacePolicy|undefined} props.policy - * @param {SpaceAction[]} props.actions - * @param {StorageDevice[]} props.devices - * @param {boolean} props.isLoading - * @param {(config: SpacePolicyConfig) => void} props.onChange - * - * @typedef {object} SpacePolicyConfig - * @property {SpacePolicy} spacePolicy - * @property {SpaceAction[]} spaceActions - */ -const SpacePolicyField = ({ - policy, - actions, - devices, - isLoading, - onChange -}) => { - const [isDialogOpen, setIsDialogOpen] = useState(false); - - const openDialog = () => setIsDialogOpen(true); - - const closeDialog = () => setIsDialogOpen(false); - - const onAccept = ({ spacePolicy, spaceActions }) => { - closeDialog(); - onChange({ spacePolicy, spaceActions }); - }; - - const label = () => { - // eslint-disable-next-line agama-i18n/string-literals - if (policy.summaryLabels.length === 1) return _(policy.summaryLabels[0]); - - // eslint-disable-next-line agama-i18n/string-literals - return sprintf(n_(policy.summaryLabels[0], policy.summaryLabels[1], devices.length), devices.length); - }; - - if (isLoading || !policy) { - return ; - } - - return ( -
- {_("Find space")} - - - } - /> -
- ); -}; - /** * Section for editing the proposal settings * @component @@ -425,27 +62,26 @@ export default function ProposalSettingsSection({ isLoading = false, onChange }) { - const changeEncryption = ({ password, method }) => { - onChange({ encryptionPassword: password, encryptionMethod: method }); + /** @param {import("~/components/storage/InstallationDeviceField").TargetConfig} targetConfig */ + const changeTarget = ({ target, targetDevice, targetPVDevices }) => { + onChange({ + target, + targetDevice: targetDevice?.name, + targetPVDevices: targetPVDevices.map(d => d.name) + }); }; - const changeBtrfsSnapshots = ({ active, settings }) => { - const rootVolume = settings.volumes.find((i) => i.mountPath === "/"); - - if (active) { - rootVolume.fsType = "Btrfs"; - rootVolume.snapshots = true; - } else { - rootVolume.snapshots = false; - } - - onChange({ volumes: settings.volumes }); + /** @param {import("~/components/storage/EncryptionField").EncryptionConfig} encryptionConfig */ + const changeEncryption = ({ password, method }) => { + onChange({ encryptionPassword: password, encryptionMethod: method }); }; + /** @param {Volume[]} volumes */ const changeVolumes = (volumes) => { onChange({ volumes }); }; + /** @param {import("~/components/storage/SpacePolicyField").SpacePolicyConfig} spacePolicyConfig */ const changeSpacePolicy = ({ spacePolicy, spaceActions }) => { onChange({ spacePolicy: spacePolicy.id, @@ -453,6 +89,7 @@ export default function ProposalSettingsSection({ }); }; + /** @param {import("~/components/storage/PartitionsField").BootConfig} bootConfig */ const changeBoot = ({ configureBoot, bootDevice }) => { onChange({ configureBoot, @@ -460,14 +97,26 @@ export default function ProposalSettingsSection({ }); }; - const targetDevice = availableDevices.find(d => d.name === settings.targetDevice); - const useEncryption = settings.encryptionPassword !== undefined && settings.encryptionPassword.length > 0; + /** + * @param {string} name + * @returns {StorageDevice|undefined} + */ + const findDevice = (name) => availableDevices.find(a => a.name === name); + + /** @type {StorageDevice|undefined} */ + const targetDevice = findDevice(settings.targetDevice); + /** @type {StorageDevice[]} */ + const targetPVDevices = compact(settings.targetPVDevices?.map(findDevice) || []); const { volumes = [], installationDevices = [], spaceActions = [] } = settings; - const bootDevice = availableDevices.find(d => d.name === settings.bootDevice); - const defaultBootDevice = availableDevices.find(d => d.name === settings.defaultBootDevice); + const bootDevice = findDevice(settings.bootDevice); + const defaultBootDevice = findDevice(settings.defaultBootDevice); const spacePolicy = SPACE_POLICIES.find(p => p.id === settings.spacePolicy); - // Templates for already existing mount points are filtered out + /** + * Templates for already existing mount points are filtered out. + * + * @returns {Volume[]} + */ const usefulTemplates = () => { const mountPaths = volumes.map(v => v.mountPath); return volumeTemplates.filter(t => ( @@ -478,34 +127,33 @@ export default function ProposalSettingsSection({ return ( <>
- - - { @@ -41,46 +40,70 @@ jest.mock("@patternfly/react-core", () => { }; }); -/** @type {Volume} */ -let volume; +/** @type {StorageDevice} */ +const sda = { + sid: 59, + isDrive: true, + type: "disk", + description: "", + vendor: "Micron", + model: "Micron 1100 SATA", + driver: ["ahci", "mmcblk"], + bus: "IDE", + busId: "", + transport: "usb", + dellBOSS: false, + sdCard: true, + active: true, + name: "/dev/sda", + size: 1024, + recoverableSize: 0, + systems : [], + udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], + udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], +}; + +/** @type {StorageDevice} */ +const sdb = { + sid: 62, + isDrive: true, + type: "disk", + description: "", + vendor: "Samsung", + model: "Samsung Evo 8 Pro", + driver: ["ahci"], + bus: "IDE", + busId: "", + transport: "", + dellBOSS: false, + sdCard: false, + active: true, + name: "/dev/sdb", + size: 2048, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: ["pci-0000:00-19"] +}; /** @type {ProposalSettingsSectionProps} */ let props; beforeEach(() => { - volume = { - mountPath: "/", - target: "DEFAULT", - fsType: "Btrfs", - minSize: 1024, - maxSize: 2048, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: true, - fsTypes: ["Btrfs", "Ext4"], - supportAutoSize: true, - snapshotsConfigurable: true, - snapshotsAffectSizes: true, - sizeRelevantVolumes: [], - adjustByRam: false - } - }; - props = { settings: { target: "DISK", + targetDevice: "/dev/sda", targetPVDevices: [], configureBoot: false, bootDevice: "", defaultBootDevice: "", encryptionPassword: "", encryptionMethod: "", - spacePolicy: "", + spacePolicy: "delete", spaceActions: [], volumes: [], - installationDevices: [] + installationDevices: [sda, sdb] }, availableDevices: [], encryptionMethods: [], @@ -89,237 +112,31 @@ beforeEach(() => { }; }); -describe("if snapshots are configurable", () => { - beforeEach(() => { - props.settings.volumes = [volume]; - }); +it("allows changing the selected device", async () => { + const { user } = installerRender(); + const button = screen.getByRole("button", { name: /installation device/i }); - it("renders the snapshots switch", () => { - plainRender(); - - screen.getByRole("checkbox", { name: "Use Btrfs Snapshots" }); - }); + await user.click(button); + await screen.findByRole("dialog", { name: /Device for installing/ }); }); -describe("if snapshots are not configurable", () => { - beforeEach(() => { - volume.outline.snapshotsConfigurable = false; - }); +it("allows changing the encryption settings", async () => { + const { user } = installerRender(); + const button = screen.getByRole("button", { name: /Encryption/ }); - it("does not render the snapshots switch", () => { - plainRender(); - - expect(screen.queryByRole("checkbox", { name: "Use Btrfs Snapshots" })).toBeNull(); - }); + await user.click(button); + await screen.findByRole("dialog", { name: /Encryption/ }); }); it("renders a section holding file systems related stuff", () => { - plainRender(); - screen.getByRole("grid", { name: "Table with mount points" }); - screen.getByRole("grid", { name: /mount points/ }); + installerRender(); + screen.getByRole("button", { name: /Partitions and file systems/ }); }); -it("requests a volume change when onChange callback is triggered", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: "Actions" }); +it("allows changing the space policy settings", async () => { + const { user } = installerRender(); + const button = screen.getByRole("button", { name: /Find space/ }); await user.click(button); - - const menu = screen.getByRole("menu"); - const reset = within(menu).getByRole("menuitem", { name: /Reset/ }); - - await user.click(reset); - - expect(props.onChange).toHaveBeenCalledWith( - { volumes: expect.any(Array) } - ); -}); - -describe("Encryption field", () => { - describe.skip("if encryption password setting is not set yet", () => { - beforeEach(() => { - // Currently settings cannot be undefined. - props.settings = undefined; - }); - - it("does not render the encryption switch", () => { - plainRender(); - - expect(screen.queryByLabelText("Use encryption")).toBeNull(); - }); - }); - - describe("if encryption password setting is set", () => { - beforeEach(() => { - props.settings.encryptionPassword = ""; - }); - - it("renders the encryption switch", () => { - plainRender(); - - screen.getByRole("checkbox", { name: "Use encryption" }); - }); - }); - - describe("if encryption password is not empty", () => { - beforeEach(() => { - props.settings.encryptionPassword = "1234"; - }); - - it("renders the encryption switch as selected", () => { - plainRender(); - - const checkbox = screen.getByRole("checkbox", { name: "Use encryption" }); - expect(checkbox).toBeChecked(); - }); - - it("renders a button for changing the encryption settings", () => { - plainRender(); - - screen.getByRole("button", { name: /Encryption settings/ }); - }); - - it("changes the selection on click", async () => { - const { user } = plainRender(); - - const checkbox = screen.getByRole("checkbox", { name: "Use encryption" }); - await user.click(checkbox); - - expect(checkbox).not.toBeChecked(); - expect(props.onChange).toHaveBeenCalled(); - }); - - it("allows changing the encryption settings when clicking on the settings button", async () => { - const { user } = plainRender(); - - const button = screen.getByRole("button", { name: /Encryption settings/ }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - screen.getByText("Encryption settings"); - - const accept = within(popup).getByRole("button", { name: "Accept" }); - await user.click(accept); - - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(props.onChange).toHaveBeenCalled(); - }); - - it("allows canceling the changes of the encryption settings", async () => { - const { user } = plainRender(); - - const button = screen.getByRole("button", { name: /Encryption settings/ }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - screen.getByText("Encryption settings"); - - const cancel = within(popup).getByRole("button", { name: "Cancel" }); - await user.click(cancel); - - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(props.onChange).not.toHaveBeenCalled(); - }); - }); - - describe("if encryption password is empty", () => { - beforeEach(() => { - props.settings.encryptionPassword = ""; - }); - - it("renders the encryption switch as not selected", () => { - plainRender(); - - const checkbox = screen.getByRole("checkbox", { name: "Use encryption" }); - expect(checkbox).not.toBeChecked(); - }); - - it("does not render a button for changing the encryption settings", () => { - plainRender(); - - const button = screen.queryByRole("button", { name: /Encryption settings/ }); - expect(button).toBeNull(); - }); - - it("changes the selection and allows changing the settings on click", async () => { - const { user } = plainRender(); - - const checkbox = screen.getByRole("checkbox", { name: "Use encryption" }); - await user.click(checkbox); - - const popup = await screen.findByRole("dialog"); - screen.getByText("Encryption settings"); - - const passwordInput = screen.getByLabelText("Password"); - const passwordConfirmInput = screen.getByLabelText("Password confirmation"); - await user.type(passwordInput, "1234"); - await user.type(passwordConfirmInput, "1234"); - const accept = within(popup).getByRole("button", { name: "Accept" }); - await user.click(accept); - - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - - expect(props.onChange).toHaveBeenCalled(); - expect(checkbox).toBeChecked(); - }); - - it("does not select encryption if the settings are canceled", async () => { - const { user } = plainRender(); - - const checkbox = screen.getByRole("checkbox", { name: "Use encryption" }); - await user.click(checkbox); - - const popup = await screen.findByRole("dialog"); - screen.getByText("Encryption settings"); - - const cancel = within(popup).getByRole("button", { name: "Cancel" }); - await user.click(cancel); - - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(props.onChange).not.toHaveBeenCalled(); - expect(checkbox).not.toBeChecked(); - }); - }); -}); - -describe("Space policy field", () => { - describe.skip("if there is no space policy", () => { - beforeEach(() => { - // Currently settings cannot be undefined. - props.settings = undefined; - }); - - it("does not render the space policy field", () => { - plainRender(); - - expect(screen.queryByLabelText("Find space")).toBeNull(); - }); - }); - - describe("if there is a space policy", () => { - beforeEach(() => { - props.settings.spacePolicy = "delete"; - }); - - it("renders the button with a text according to given policy", () => { - const { rerender } = plainRender(); - screen.getByRole("button", { name: /deleting/ }); - - props.settings.spacePolicy = "resize"; - rerender(); - screen.getByRole("button", { name: /shrinking/ }); - }); - - it("allows to change the policy", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: /deleting all content/ }); - - await user.click(button); - - const popup = await screen.findByRole("dialog"); - within(popup).getByText("Find space"); - const cancel = within(popup).getByRole("button", { name: "Cancel" }); - await user.click(cancel); - }); - }); + await screen.findByRole("dialog", { name: /Find space/ }); }); diff --git a/web/src/components/storage/SnapshotsField.jsx b/web/src/components/storage/SnapshotsField.jsx new file mode 100644 index 0000000000..d5ba48a25d --- /dev/null +++ b/web/src/components/storage/SnapshotsField.jsx @@ -0,0 +1,69 @@ +/* + * 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 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. + */ + +// @ts-check + +import React from "react"; + +import { _ } from "~/i18n"; +import { noop } from "~/utils"; +import { hasFS } from "~/components/storage/utils"; +import { SwitchField } from "~/components/core"; + +/** + * @typedef {import ("~/client/storage").ProposalSettings} ProposalSettings + * @typedef {import ("~/client/storage").Volume} Volume + */ + +const LABEL = _("Use Btrfs snapshots for the root file system"); +const DESCRIPTION = _("Allows to boot to a previous version of the \ +system after configuration changes or software upgrades."); + +/** + * Allows to define snapshots enablement + * @component + * + * @typedef {object} SnapshotsFieldProps + * @property {Volume} rootVolume + * @property {(config: object) => void} onChange + * + * @param {SnapshotsFieldProps} props + */ +export default function SnapshotsField({ + rootVolume, + onChange = noop +}) { + const isChecked = hasFS(rootVolume, "Btrfs") && rootVolume.snapshots; + + const switchState = () => { + onChange({ active: !isChecked }); + }; + + return ( + + ); +} diff --git a/web/src/components/storage/SnapshotsField.test.jsx b/web/src/components/storage/SnapshotsField.test.jsx new file mode 100644 index 0000000000..4b2bb43ae5 --- /dev/null +++ b/web/src/components/storage/SnapshotsField.test.jsx @@ -0,0 +1,91 @@ +/* + * 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 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. + */ + +// @ts-check + +import React from "react"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import SnapshotsField from "~/components/storage/SnapshotsField"; + +/** + * @typedef {import ("~/client/storage").Volume} Volume + * @typedef {import ("~/components/storage/SnapshotsField").SnapshotsFieldProps} SnapshotsFieldProps + */ + +/** @type {Volume} */ +const rootVolume = { + mountPath: "/", + target: "DEFAULT", + fsType: "Btrfs", + minSize: 1024, + autoSize: true, + snapshots: true, + transactional: false, + outline: { + required: true, + fsTypes: ["ext4", "btrfs"], + supportAutoSize: true, + snapshotsConfigurable: false, + snapshotsAffectSizes: true, + adjustByRam: false, + sizeRelevantVolumes: ["/home"] + } +}; + +const onChangeFn = jest.fn(); + +/** @type {SnapshotsFieldProps} */ +let props; + +describe("SnapshotsField", () => { + it("reflects snapshots status", () => { + let button; + + props = { rootVolume: { ...rootVolume, snapshots: true }, onChange: onChangeFn }; + const { rerender } = plainRender(); + button = screen.getByRole("switch"); + expect(button).toHaveAttribute("aria-checked", "true"); + + props = { rootVolume: { ...rootVolume, snapshots: false }, onChange: onChangeFn }; + rerender(); + button = screen.getByRole("switch"); + expect(button).toHaveAttribute("aria-checked", "false"); + }); + + it("allows toggling snapshots status", async () => { + let button; + + props = { rootVolume: { ...rootVolume, snapshots: true }, onChange: onChangeFn }; + const { user, rerender } = plainRender(); + button = screen.getByRole("switch"); + expect(button).toHaveAttribute("aria-checked", "true"); + await user.click(button); + expect(onChangeFn).toHaveBeenCalledWith({ active: false }); + + props = { rootVolume: { ...rootVolume, snapshots: false }, onChange: onChangeFn }; + rerender(); + button = screen.getByRole("switch"); + expect(button).toHaveAttribute("aria-checked", "false"); + await user.click(button); + expect(onChangeFn).toHaveBeenCalledWith({ active: true }); + }); +}); diff --git a/web/src/components/storage/SpacePolicyField.jsx b/web/src/components/storage/SpacePolicyField.jsx new file mode 100644 index 0000000000..3208820dcc --- /dev/null +++ b/web/src/components/storage/SpacePolicyField.jsx @@ -0,0 +1,107 @@ +/* + * 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 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. + */ + +// @ts-check + +import React, { useState } from "react"; +import { Skeleton } from "@patternfly/react-core"; + +import { sprintf } from "sprintf-js"; +import { _, n_ } from "~/i18n"; +import { If, SettingsField } from "~/components/core"; +import SpacePolicyDialog from "~/components/storage/SpacePolicyDialog"; + +/** + * @typedef {import ("~/client/storage").SpaceAction} SpaceAction + * @typedef {import ("~/components/storage/utils").SpacePolicy} SpacePolicy + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + */ + +/** + * Allows to select the space policy. + * @component + * + * @typedef {object} SpacePolicyFieldProps + * @property {SpacePolicy|undefined} policy + * @property {SpaceAction[]} actions + * @property {StorageDevice[]} devices + * @property {boolean} isLoading + * @property {(config: SpacePolicyConfig) => void} onChange + * + * @typedef {object} SpacePolicyConfig + * @property {SpacePolicy} spacePolicy + * @property {SpaceAction[]} spaceActions + * + * @param {SpacePolicyFieldProps} props + */ +export default function SpacePolicyField({ + policy, + actions, + devices, + isLoading, + onChange +}) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const openDialog = () => setIsDialogOpen(true); + + const closeDialog = () => setIsDialogOpen(false); + + const onAccept = ({ spacePolicy, spaceActions }) => { + closeDialog(); + onChange({ spacePolicy, spaceActions }); + }; + + let value; + if (isLoading || !policy) { + value = ; + } else if (policy.summaryLabels.length === 1) { + // eslint-disable-next-line agama-i18n/string-literals + value = _(policy.summaryLabels[0]); + } else { + // eslint-disable-next-line agama-i18n/string-literals + value = sprintf(n_(policy.summaryLabels[0], policy.summaryLabels[1], devices.length), devices.length); + } + + return ( + + + } + /> + + ); +} diff --git a/web/src/components/storage/SpacePolicyField.test.jsx b/web/src/components/storage/SpacePolicyField.test.jsx new file mode 100644 index 0000000000..a8b70c31fe --- /dev/null +++ b/web/src/components/storage/SpacePolicyField.test.jsx @@ -0,0 +1,71 @@ +/* + * 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 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. + */ + +// @ts-check + +import React from "react"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { SPACE_POLICIES } from "~/components/storage/utils"; +import SpacePolicyField from "~/components/storage/SpacePolicyField"; + +const sda = { + sid: 59, + description: "A fake disk for testing", + isDrive: true, + type: "disk", + vendor: "Micron", + model: "Micron 1100 SATA", + driver: ["ahci", "mmcblk"], + bus: "IDE", + busId: "", + transport: "usb", + dellBOSS: false, + sdCard: true, + active: true, + name: "/dev/sda", + size: 1024, + recoverableSize: 0, + systems : [], + udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], + udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], +}; + +const keepPolicy = SPACE_POLICIES.find(p => p.id === "keep"); + +const props = { + devices: [sda], + policy: keepPolicy, + isLoading: false, + onChange: jest.fn(), + actions: [ + { device: "/dev/sda", action: "force_delete" }, + ] +}; + +describe("SpacePolicyField", () => { + it("renders a button for opening the space policy dialog", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button"); + await user.click(button); + screen.getByRole("dialog", { name: "Find space" }); + }); +}); diff --git a/web/src/components/storage/index.js b/web/src/components/storage/index.js index d0e3db5f5c..971542f903 100644 --- a/web/src/components/storage/index.js +++ b/web/src/components/storage/index.js @@ -22,11 +22,9 @@ export { default as ProposalPage } from "./ProposalPage"; export { default as ProposalPageMenu } from "./ProposalPageMenu"; export { default as ProposalSettingsSection } from "./ProposalSettingsSection"; -export { default as ProposalDeviceSection } from "./ProposalDeviceSection"; export { default as ProposalTransactionalInfo } from "./ProposalTransactionalInfo"; export { default as ProposalActionsDialog } from "./ProposalActionsDialog"; export { default as ProposalResultSection } from "./ProposalResultSection"; -export { default as ProposalVolumes } from "./ProposalVolumes"; export { default as DASDPage } from "./DASDPage"; export { default as DASDTable } from "./DASDTable"; export { default as DASDFormatProgress } from "./DASDFormatProgress"; diff --git a/web/src/components/storage/utils.js b/web/src/components/storage/utils.js index 6e45ad190c..1e842c211c 100644 --- a/web/src/components/storage/utils.js +++ b/web/src/components/storage/utils.js @@ -215,7 +215,7 @@ const deviceLabel = (device) => { const deviceChildren = (device) => { const partitionTableChildren = (partitionTable) => { const { partitions, unusedSlots } = partitionTable; - const children = partitions.concat(unusedSlots); + const children = partitions.concat(unusedSlots).filter(i => !!i); return children.sort((a, b) => a.start < b.start ? -1 : 1); };