diff --git a/web/src/components/storage/ProposalDeviceSection.jsx b/web/src/components/storage/ProposalDeviceSection.jsx
new file mode 100644
index 0000000000..8c2f3dc61c
--- /dev/null
+++ b/web/src/components/storage/ProposalDeviceSection.jsx
@@ -0,0 +1,208 @@
+/*
+ * 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, { useState } from "react";
+import { Button, Form, Skeleton } from "@patternfly/react-core";
+
+import { _ } from "~/i18n";
+import { If, Section, Popup } from "~/components/core";
+import { DeviceSelector } from "~/components/storage";
+import { deviceLabel } from '~/components/storage/utils';
+import { noop } from "~/utils";
+
+/**
+ * @typedef {import ("~/client/storage").ProposalManager.ProposalSettings} ProposalSettings
+ * @typedef {import ("~/client/storage").DevicesManager.StorageDevice} StorageDevice
+ */
+
+/**
+ * Form for selecting the installation device.
+ * @component
+ *
+ * @param {object} props
+ * @param {string} props.id - Form ID.
+ * @param {StorageDevice|undefined} [props.current] - Currently selected device, if any.
+ * @param {StorageDevice[]} [props.devices=[]] - Available devices for the selection.
+ * @param {onSubmitFn} [props.onSubmit=noop] - On submit callback.
+ *
+ * @callback onSubmitFn
+ * @param {string} device - Name of the selected device.
+ */
+const InstallationDeviceForm = ({
+ id,
+ current,
+ devices = [],
+ onSubmit = noop
+}) => {
+ const [device, setDevice] = useState(current || devices[0]);
+
+ const changeSelected = (deviceId) => {
+ setDevice(devices.find(d => d.sid === deviceId));
+ };
+
+ const submitForm = (e) => {
+ e.preventDefault();
+ if (device !== undefined) onSubmit(device);
+ };
+
+ return (
+
+ );
+};
+
+/**
+ * Allows to select the installation device.
+ * @component
+ *
+ * @callback onChangeFn
+ * @param {string} device - Name of the selected device.
+ *
+ * @param {object} props
+ * @param {string} [props.current] - Device name, if any.
+ * @param {StorageDevice[]} [props.devices=[]] - Available devices for the selection.
+ * @param {boolean} [props.isLoading=false] - Whether to show the selector as loading.
+ * @param {onChangeFn} [props.onChange=noop] - On change callback.
+ */
+const InstallationDeviceField = ({
+ current,
+ devices = [],
+ isLoading = false,
+ onChange = noop
+}) => {
+ const [device, setDevice] = useState(devices.find(d => d.name === current));
+ const [isFormOpen, setIsFormOpen] = useState(false);
+
+ const openForm = () => setIsFormOpen(true);
+
+ const closeForm = () => setIsFormOpen(false);
+
+ const acceptForm = (selectedDevice) => {
+ closeForm();
+ setDevice(selectedDevice);
+ onChange(selectedDevice);
+ };
+
+ /**
+ * Renders a button that allows changing selected device
+ *
+ * NOTE: if a device is already selected, its name and size will be used for
+ * the button text. Otherwise, a "No device selected" text will be shown.
+ *
+ * @param {object} props
+ * @param {StorageDevice|undefined} [props.current] - Currently selected device, if any.
+ */
+ const DeviceContent = ({ device }) => {
+ return (
+
+ );
+ };
+
+ if (isLoading) {
+ return ;
+ }
+
+ const description = _("Select the device for installing the system.");
+
+ return (
+ <>
+
+ {_("Installation device")}
+
+
+
+
+ }
+ />
+
+
+ {_("Accept")}
+
+
+
+
+ >
+ );
+};
+
+/**
+ * Section for editing the selected device
+ * @component
+ *
+ * @callback onChangeFn
+ * @param {object} settings
+ *
+ * @param {object} props
+ * @param {ProposalSettings} props.settings
+ * @param {StorageDevice[]} [props.availableDevices=[]]
+ * @param {boolean} [isLoading=false]
+ * @param {onChangeFn} [props.onChange=noop]
+ */
+export default function ProposalDeviceSection({
+ settings,
+ availableDevices = [],
+ isLoading = false,
+ onChange = noop
+}) {
+ // FIXME: we should work with devices objects ASAP
+ const { bootDevice } = settings;
+
+ const changeBootDevice = (device) => {
+ if (device.name !== bootDevice) {
+ onChange({ bootDevice: device.name });
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/web/src/components/storage/ProposalDeviceSection.test.jsx b/web/src/components/storage/ProposalDeviceSection.test.jsx
new file mode 100644
index 0000000000..e90db6a054
--- /dev/null
+++ b/web/src/components/storage/ProposalDeviceSection.test.jsx
@@ -0,0 +1,182 @@
+/*
+ * 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, 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"]
+};
+
+const props = {
+ settings: {
+ bootDevice: "/dev/sda",
+ },
+ availableDevices: [sda, sdb],
+ isLoading: false,
+ onChange: jest.fn()
+};
+
+describe("ProposalDeviceSection", () => {
+ describe("when set as loading", () => {
+ beforeEach(() => {
+ props.isLoading = true;
+ });
+
+ describe("and selected device is not defined yet", () => {
+ beforeEach(() => {
+ props.settings = { bootDevice: undefined };
+ });
+
+ it("renders a loading hint", () => {
+ plainRender();
+ screen.getByText("Loading selected device");
+ });
+ });
+ });
+
+ describe("when installation device is not selected yet", () => {
+ beforeEach(() => {
+ props.settings = { bootDevice: "" };
+ });
+
+ 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: "Installation device" });
+ });
+ });
+
+ describe("when installation device is selected", () => {
+ beforeEach(() => {
+ props.settings = { bootDevice: "/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: "Installation device" });
+ });
+ });
+
+ 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: "Installation device" });
+ const sdbOption = within(selector).getByRole("radio", { name: /sdb/ });
+ const accept = within(selector).getByRole("button", { name: "Accept" });
+
+ await user.click(sdbOption);
+ await user.click(accept);
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ expect(props.onChange).toHaveBeenCalledWith({ bootDevice: sdb.name });
+ });
+
+ 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: "Installation device" });
+ const sdbOption = within(selector).getByRole("radio", { name: /sdb/ });
+ 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();
+ });
+
+ it("does not trigger the onChange callback when selection actually did not change", 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: "Installation device" });
+ const sdaOption = within(selector).getByRole("radio", { name: /sda/ });
+ const sdbOption = within(selector).getByRole("radio", { name: /sdb/ });
+ const accept = within(selector).getByRole("button", { name: "Accept" });
+
+ // User selects a different device
+ await user.click(sdbOption);
+ // but then goes back to the selected device
+ await user.click(sdaOption);
+ // and clicks on Accept button
+ await user.click(accept);
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ // There is no reason for triggering the onChange callback
+ expect(props.onChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/web/src/components/storage/ProposalPage.jsx b/web/src/components/storage/ProposalPage.jsx
index 4d730cce93..faeb34f240 100644
--- a/web/src/components/storage/ProposalPage.jsx
+++ b/web/src/components/storage/ProposalPage.jsx
@@ -32,6 +32,7 @@ import {
ProposalPageMenu,
ProposalSettingsSection,
ProposalSpacePolicySection,
+ ProposalDeviceSection,
} from "~/components/storage";
import { IDLE } from "~/client/status";
@@ -215,6 +216,12 @@ export default function ProposalPage() {
customIcon={}
title={_("Devices will not be modified until installation starts.")}
/>
+ {
- const [device, setDevice] = useState(current || devices[0]);
-
- const changeSelected = (deviceId) => {
- setDevice(devices.find(d => d.sid === deviceId));
- };
-
- const submitForm = (e) => {
- e.preventDefault();
- if (device !== undefined) onSubmit(device);
- };
-
- return (
-
- );
-};
-
-/**
- * Allows to select the installation device.
- * @component
- *
- * @param {object} props
- * @param {string} [props.current] - Device name, if any.
- * @param {StorageDevice[]} [props.devices=[]] - Available devices for the selection.
- * @param {boolean} [props.isLoading=false] - Whether to show the selector as loading.
- * @param {onChangeFn} [props.onChange=noop] - On change callback.
- *
- * @callback onChangeFn
- * @param {string} device - Name of the selected device.
- */
-const InstallationDeviceField = ({
- current,
- devices = [],
- isLoading = false,
- onChange = noop
-}) => {
- const [device, setDevice] = useState(devices.find(d => d.name === current));
- const [isFormOpen, setIsFormOpen] = useState(false);
-
- const openForm = () => setIsFormOpen(true);
-
- const closeForm = () => setIsFormOpen(false);
-
- const acceptForm = (selectedDevice) => {
- closeForm();
- setDevice(selectedDevice);
- onChange(selectedDevice);
- };
-
- /**
- * Renders a button that allows changing selected device
- *
- * NOTE: if a device is already selected, its name and size will be used for
- * the button text. Otherwise, a "No device selected" text will be shown.
- *
- * @param {object} props
- * @param {StorageDevice|undefined} [props.current] - Currently selected device, if any.
- */
- const DeviceContent = ({ device }) => {
- return (
-
- );
- };
-
- if (isLoading) {
- return ;
- }
-
- const description = _("Select the device for installing the system.");
-
- return (
- <>
-
- {_("Installation device")}
-
-
-
-
- }
- />
-
-
- {_("Accept")}
-
-
-
-
- >
- );
-};
-
/**
* Form for configuring the system volume group.
* @component
@@ -575,11 +439,6 @@ export default function ProposalSettingsSection({
isLoading = false,
onChange = noop
}) {
- // FIXME: we should work with devices objects ASAP
- const changeBootDevice = (device) => {
- onChange({ bootDevice: device.name });
- };
-
const changeLVM = ({ lvm, vgDevices }) => {
const settings = {};
if (lvm !== undefined) settings.lvm = lvm;
@@ -596,18 +455,11 @@ export default function ProposalSettingsSection({
onChange({ volumes });
};
- const { bootDevice } = settings;
const encryption = settings.encryptionPassword !== undefined && settings.encryptionPassword.length > 0;
return (
<>
- {
props = {};
});
-describe("Installation device field", () => {
- describe("if it is loading", () => {
- beforeEach(() => {
- props.isLoading = true;
- });
-
- describe("and there is no selected device yet", () => {
- beforeEach(() => {
- props.settings = { bootDevice: "" };
- });
-
- it("renders a message indicating that the device is not selected", () => {
- plainRender();
-
- screen.getByText(/Installation device/);
- screen.getByText(/No device selected/);
- });
- });
-
- // FIXME: skipping this test example by now.
- // It will be addressed when reworking the Device section
- // as part of https://github.com/openSUSE/agama/pull/1031
- describe.skip("and there is a selected device", () => {
- beforeEach(() => {
- props.settings = { bootDevice: "/dev/vda" };
- });
-
- it("renders the selected device", () => {
- plainRender();
-
- screen.getByText(/Installation device/);
- screen.getByText("/dev/vda");
- });
- });
- });
-
- describe("if there is no selected device yet", () => {
- beforeEach(() => {
- props.settings = { bootDevice: "" };
- });
-
- it("renders a message indicating that the device is not selected", () => {
- plainRender();
-
- screen.getByText(/Installation device/);
- screen.getByText(/No device selected/);
- });
- });
-
- // FIXME: skipping this test example by now.
- // It will be addressed when reworking the Device section
- // as part of https://github.com/openSUSE/agama/pull/1031
- describe.skip("if there is a selected device", () => {
- beforeEach(() => {
- props.settings = { bootDevice: "/dev/vda" };
- });
-
- it("renders the selected device", () => {
- plainRender();
-
- screen.getByText(/Installation device/);
- screen.getByText("/dev/vda");
- });
- });
-
- it("allows selecting a device when clicking on the device name", async () => {
- props = {
- availableDevices: [vda],
- settings: { bootDevice: "/dev/vda" },
- onChange: jest.fn()
- };
-
- const { user } = plainRender();
-
- const button = screen.getByRole("button", { name: "/dev/vda, 1 KiB" });
- await user.click(button);
-
- const popup = await screen.findByRole("dialog");
- within(popup).getByText("Installation device");
-
- 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 selection of the device", async () => {
- props = {
- availableDevices: [vda],
- settings: { bootDevice: "/dev/vda" },
- onChange: jest.fn()
- };
-
- const { user } = plainRender();
-
- const button = screen.getByRole("button", { name: "/dev/vda, 1 KiB" });
- await user.click(button);
-
- const popup = await screen.findByRole("dialog");
- within(popup).getByText("Installation device");
-
- const cancel = within(popup).getByRole("button", { name: "Cancel" });
- await user.click(cancel);
-
- expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
- expect(props.onChange).not.toHaveBeenCalled();
- });
-});
-
describe("LVM field", () => {
describe("if LVM setting is not set yet", () => {
beforeEach(() => {
diff --git a/web/src/components/storage/index.js b/web/src/components/storage/index.js
index 14717598a8..5cccae8494 100644
--- a/web/src/components/storage/index.js
+++ b/web/src/components/storage/index.js
@@ -23,6 +23,7 @@ export { default as ProposalPage } from "./ProposalPage";
export { default as ProposalPageMenu } from "./ProposalPageMenu";
export { default as ProposalSettingsSection } from "./ProposalSettingsSection";
export { default as ProposalSpacePolicySection } from "./ProposalSpacePolicySection";
+export { default as ProposalDeviceSection } from "./ProposalDeviceSection";
export { default as ProposalActionsSection } from "./ProposalActionsSection";
export { default as ProposalVolumes } from "./ProposalVolumes";
export { default as DASDPage } from "./DASDPage";