diff --git a/web/src/assets/styles/utilities.scss b/web/src/assets/styles/utilities.scss index ae12b38719..63d1422b2c 100644 --- a/web/src/assets/styles/utilities.scss +++ b/web/src/assets/styles/utilities.scss @@ -138,6 +138,14 @@ padding: 0; } +.inline-flex-button{ + @extend .plain-button; + display: inline-flex; + align-items: center; + gap: 0.7ch; + text-decoration: underline; +} + .p-0 { padding: 0; } diff --git a/web/src/components/layout/Icon.jsx b/web/src/components/layout/Icon.jsx index 33a1f229e0..848d4859cc 100644 --- a/web/src/components/layout/Icon.jsx +++ b/web/src/components/layout/Icon.jsx @@ -38,6 +38,7 @@ 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"; @@ -61,6 +62,7 @@ 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"; @@ -104,6 +106,7 @@ const icons = { error: Error, expand_all: ExpandAll, expand_more: ExpandMore, + feedback: Feedback, folder: Folder, folder_off: FolderOff, globe: Globe, @@ -128,6 +131,7 @@ const icons = { settings: SettingsFill, settings_applications: SettingsApplications, settings_ethernet: SettingsEthernet, + shadow: Shadow, signal_cellular_alt: SignalCellularAlt, storage: Storage, sync: Sync, diff --git a/web/src/components/storage/BootConfigField.jsx b/web/src/components/storage/BootConfigField.jsx new file mode 100644 index 0000000000..483e5d9f18 --- /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: Boot) => void} props.onChange + * + * @typedef {object} Boot + * @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 create boot partitions.")}; + } else if (!bootDevice) { + value = _("Installation might create boot partitions at the installation device."); + } else { + // TRANSLATORS: %s is the disk used to configure the boot-related partitions (eg. "/dev/sda, 80 GiB) + value = sprintf(_("Installation might create boot partitions 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..f4945e46bb --- /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 create boot partitions/); + }); + }); + + describe("when installation is set for automatically configuring boot", () => { + it("renders a text reporting about it", () => { + plainRender(); + screen.getByText(/create boot partitions at the installation device/); + }); + }); + + describe("when installation is set for configuring boot at specific device", () => { + it("renders a text reporting about it", () => { + plainRender(); + screen.getByText(/boot partitions at \/dev\/sda/); + }); + }); +}); diff --git a/web/src/components/storage/BootSelectionDialog.test.jsx b/web/src/components/storage/BootSelectionDialog.test.jsx index 7e759a1115..395c0e4ebb 100644 --- a/web/src/components/storage/BootSelectionDialog.test.jsx +++ b/web/src/components/storage/BootSelectionDialog.test.jsx @@ -19,6 +19,8 @@ * 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"; diff --git a/web/src/components/storage/ProposalSettingsSection.jsx b/web/src/components/storage/ProposalSettingsSection.jsx index 57e10656d3..d7f9453ab8 100644 --- a/web/src/components/storage/ProposalSettingsSection.jsx +++ b/web/src/components/storage/ProposalSettingsSection.jsx @@ -22,16 +22,16 @@ // @ts-check import React, { useEffect, useState } from "react"; -import { Button, Checkbox, Form, Skeleton, Switch, Tooltip } from "@patternfly/react-core"; +import { Checkbox, Form, Skeleton, Switch, Tooltip } from "@patternfly/react-core"; -import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; -import { BootSelectionDialog, ProposalVolumes } from "~/components/storage"; +import { ProposalVolumes } from "~/components/storage"; import SpacePolicyField from "~/components/storage/SpacePolicyField"; +import BootConfigField from "~/components/storage/BootConfigField"; 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 { hasFS, SPACE_POLICIES } from "~/components/storage/utils"; /** * @typedef {import ("~/client/storage").ProposalSettings} ProposalSettings @@ -266,78 +266,6 @@ const EncryptionField = ({ ); }; -/** - * 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} - - - } - /> -
- ); -}; - /** * Section for editing the proposal settings * @component