diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss
index 43b336bf31..826fd6ed3b 100644
--- a/web/src/assets/styles/blocks.scss
+++ b/web/src/assets/styles/blocks.scss
@@ -424,6 +424,89 @@ ul[data-type="agama/list"][role="grid"] {
}
}
+[data-type="agama/segmented-control"] {
+ ul {
+ display: inline-flex;
+ gap: var(--spacer-smaller);
+ list-style-type: none;
+ border-radius: var(--spacer-smaller);
+ align-items: center;
+ margin-inline: 0;
+
+ li {
+ margin: 0;
+
+ &:not(:last-child) {
+ padding-inline-end: var(--spacer-smaller);
+ border-inline-end: 1px solid var(--color-gray-dark);
+ }
+
+ button {
+ padding: var(--spacer-smaller) var(--spacer-small);
+ border: 1px solid var(--color-gray-darker);
+ border-radius: var(--spacer-smaller);
+ background: white; // var(--color-gray);
+
+ &[aria-current="true"] {
+ background: var(--color-primary);
+ color: white;
+ font-weight: bold;
+ font-size: var(--fs-normal);
+ }
+
+ &:not([aria-current="true"]):hover {
+ background: var(--color-gray);
+ }
+ }
+ }
+ }
+}
+
+[data-type="agama/controlled-panels"] {
+ [data-type="agama/option"] {
+ label, input {
+ cursor: pointer;
+ }
+
+ label {
+ display: flex;
+ gap: var(--spacer-smaller);
+ }
+ }
+
+ [data-variant="buttons"] {
+ input { position: absolute; opacity: 0 }
+
+ label {
+ border: 1px solid var(--color-primary);
+ padding: var(--spacer-small);
+ gap: var(--spacer-small);
+ border-radius: var(--spacer-smaller);
+ position: relative;
+
+ &:has(input:checked) {
+ background: var(--color-primary);
+ color: white;
+ }
+
+ &:has(input:focus-visible) {
+ // outline: 1px dotted;
+ // outline-offset: 0.25rem;
+ box-shadow: 0 0 0 3px var(--focus-color);
+ }
+ }
+
+ [data-type="agama/option"]:not(:last-child) {
+ border-inline-end: 2px solid var(--color-gray-darker);
+ padding-inline-end: var(--spacer-small);
+ }
+ }
+
+ > div[aria-expanded="false"] {
+ display: none;
+ }
+}
+
table[data-type="agama/tree-table"] {
th:first-child {
block-size: fit-content;
@@ -670,3 +753,27 @@ section [data-type="agama/reminder"] {
max-inline-size: 100%;
}
}
+
+
+[data-type="agama/expandable-selector"] {
+ // The expandable selector is built on top of PF/Table#expandable
+ // Let's tweak some styles
+ tr {
+ td:first-child {
+ padding-inline-start: 0;
+ }
+
+ td:last-child {
+ padding-inline-end: 0;
+ }
+ }
+
+ tr.pf-v5-c-table__expandable-row.pf-m-expanded {
+ border-bottom: 0;
+
+ .pf-v5-c-table__expandable-row-content {
+ font-size: var(--fs-medium);
+ padding-block: var(--spacer-small);
+ }
+ }
+}
diff --git a/web/src/assets/styles/variables.scss b/web/src/assets/styles/variables.scss
index 490429fe54..71a5d7e024 100644
--- a/web/src/assets/styles/variables.scss
+++ b/web/src/assets/styles/variables.scss
@@ -9,6 +9,7 @@
--fw-bold: 700;
--fs-small: 0.7rem;
+ --fs-medium: 12px;
--fs-base: 14px;
--fs-large: 1rem;
--fs-h1: 1.5rem;
diff --git a/web/src/components/core/ControlledPanels.jsx b/web/src/components/core/ControlledPanels.jsx
new file mode 100644
index 0000000000..6c164607f9
--- /dev/null
+++ b/web/src/components/core/ControlledPanels.jsx
@@ -0,0 +1,108 @@
+/*
+ * 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";
+
+/**
+ * Wrapper component for holding ControlledPanel options
+ *
+ * Useful for rendering the ControlledPanel options horizontally.
+ *
+ * @see ControlledPanel examples.
+ *
+ * @param {React.PropsWithChildren} props
+ */
+const Options = ({ children, ...props }) => {
+ return (
+
+ { children }
+
+ );
+};
+
+/**
+ * Renders an option intended to control the visibility of panels referenced by
+ * the controls prop.
+ *
+ * @typedef {object} OptionProps
+ * @property {string} id - The option id.
+ * @property {React.AriaAttributes["aria-controls"]} controls - A space-separated of one or more ID values
+ * referencing the elements whose contents or presence are controlled by the option.
+ * @property {boolean} isSelected - Whether the option is selected or not.
+ * @typedef {Omit, "aria-controls">} InputProps
+ *
+ * @param {React.PropsWithChildren} props
+ */
+const Option = ({ id, controls, isSelected, children, ...props }) => {
+ return (
+
+
+
+ {children}
+
+
+ );
+};
+
+/**
+ * Renders content whose visibility will be controlled by an option
+ *
+ * @typedef {object} PanelBaseProps
+ * @property {string} id - The option id.
+ * @property {boolean} isExpanded - The value for the aria-expanded attribute
+ * which will determine if the panel is visible or not.
+ *
+ * @typedef {PanelBaseProps & Omit, "id" | "aria-expanded">} PanelProps
+ *
+ * @param {PanelProps} props
+ */
+const Panel = ({ id, isExpanded = false, children, ...props }) => {
+ return (
+
+ { children }
+
+ );
+};
+
+/**
+ * TODO: Write the documentation and examples.
+ * TODO: Find a better name.
+ * TODO: Improve it.
+ * NOTE: Please, be aware that despite the name, this has no relation with so
+ * called React controlled components https://react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components
+ * This is just a convenient, dummy component for simplifying the use of this
+ * options/tabs pattern across Agama UI.
+ */
+const ControlledPanels = ({ children, ...props }) => {
+ return (
+
+ { children }
+
+ );
+};
+
+ControlledPanels.Options = Options;
+ControlledPanels.Option = Option;
+ControlledPanels.Panel = Panel;
+
+export default ControlledPanels;
diff --git a/web/src/components/core/ExpandableSelector.jsx b/web/src/components/core/ExpandableSelector.jsx
new file mode 100644
index 0000000000..01c159b5f6
--- /dev/null
+++ b/web/src/components/core/ExpandableSelector.jsx
@@ -0,0 +1,237 @@
+/*
+ * 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 { Table, Thead, Tr, Th, Tbody, Td, ExpandableRowContent, RowSelectVariant } from "@patternfly/react-table";
+
+/**
+ * An object for sharing data across nested maps
+ *
+ * Since function arguments are always passed by value, an object passed by
+ * sharing is needed for sharing data that might be mutated from different
+ * places, as it is the case of the rowIndex prop here.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions#passing_arguments
+ *
+ * @typedef {object} SharedData
+ * @property {number} rowIndex - The current row index, to be incremented each time a table row is generated.
+ */
+
+/**
+ * @typedef {object} ExpandableSelectorColumn
+ * @property {string} name - The column header text
+ * @property {(object) => JSX.Element} value - A function receiving
+ * the item to work with and returning the column value.
+ */
+
+/**
+ * Internal component for building the table header
+ *
+ * @param {object} props
+ * @param {ExpandableSelectorColumn[]} props.columns
+ */
+const TableHeader = ({ columns }) => (
+
+
+
+
+ { columns?.map((c, i) => {c.name} ) }
+
+
+);
+
+/**
+ * Helper function for ensuring a good value for ExpandableSelector#itemsSelected prop
+ *
+ * It logs information to console.error if given value does not match
+ * expectations.
+ *
+ * @param {*} selection - The value to check.
+ * @param {boolean} allowMultiple - Whether the returned collection can have
+ * more than one item
+ * @return {Array} Empty array if given value is not valid. The first element if
+ * it is a collection with more than one but selector does not allow multiple.
+ * The original value otherwise.
+ */
+const sanitizeSelection = (selection, allowMultiple) => {
+ if (!Array.isArray(selection)) {
+ console.error("`itemSelected` prop must be an array. Ignoring given value", selection);
+ return [];
+ }
+
+ if (!allowMultiple && selection.length > 1) {
+ console.error(
+ "`itemsSelected` prop can only have more than one item when selector `isMultiple`. " +
+ "Using only the first element"
+ );
+
+ return [selection[0]];
+ }
+
+ return selection;
+};
+
+/**
+ * Build a expandable table with selectable items
+ * @component
+ *
+ * @note It only accepts one nesting level.
+ *
+ * @param {object} props
+ * @param {ExpandableSelectorColumn[]} props.columns - Collection of objects defining columns.
+ * @param {boolean} [props.isMultiple=false] - Whether multiple selection is allowed.
+ * @param {object[]} props.items - Collection of items to be rendered.
+ * @param {string} [props.itemIdKey="id"] - The key for retrieving the item id.
+ * @param {(item: object) => Array} [props.itemChildren=() => []] - Lookup method to retrieve children from given item.
+ * @param {(item: object) => boolean} [props.itemSelectable=() => true] - Whether an item will be selectable or not.
+ * @param {object[]} [props.itemsSelected=[]] - Collection of selected items.
+ * @param {string[]} [props.initialExpandedKeys=[]] - Ids of initially expanded items.
+ * @param {(selection: Array) => void} [props.onSelectionChange=noop] - Callback to be triggered when selection changes.
+ * @param {object} [props.tableProps] - Props for {@link https://www.patternfly.org/components/table/#table PF/Table}.
+ */
+export default function ExpandableSelector({
+ columns = [],
+ isMultiple = false,
+ items = [],
+ itemIdKey = "id",
+ itemChildren = () => [],
+ itemSelectable = () => true,
+ itemsSelected = [],
+ initialExpandedKeys = [],
+ onSelectionChange,
+ ...tableProps
+}) {
+ const [expandedItemsKeys, setExpandedItemsKeys] = useState(initialExpandedKeys);
+ const selection = sanitizeSelection(itemsSelected, isMultiple);
+ const isItemSelected = (item) => selection.includes(item);
+ const isItemExpanded = (key) => expandedItemsKeys.includes(key);
+ const toggleExpanded = (key) => {
+ if (isItemExpanded(key)) {
+ setExpandedItemsKeys(expandedItemsKeys.filter(k => k !== key));
+ } else {
+ setExpandedItemsKeys([...expandedItemsKeys, key]);
+ }
+ };
+
+ const updateSelection = (item) => {
+ if (!isMultiple) {
+ onSelectionChange([item]);
+ return;
+ }
+
+ if (isItemSelected(item)) {
+ onSelectionChange(selection.filter(i => i !== item));
+ } else {
+ onSelectionChange([...selection, item]);
+ }
+ };
+
+ /**
+ * Render method for building the markup for an item child
+ *
+ * @param {object} item - The child to be rendered
+ * @param {boolean} isExpanded - Whether the child should be shown or not
+ * @param {SharedData} sharedData - An object holding shared data
+ */
+ const renderItemChild = (item, isExpanded, sharedData) => {
+ const rowIndex = sharedData.rowIndex++;
+
+ const selectProps = {
+ rowIndex,
+ onSelect: () => updateSelection(item),
+ isSelected: isItemSelected(item),
+ variant: isMultiple ? RowSelectVariant.checkbox : RowSelectVariant.radio
+ };
+
+ return (
+
+
+
+ { columns?.map((column, index) => (
+
+ {column.value(item)}
+
+ ))}
+
+ );
+ };
+
+ /**
+ * Render method for building the markup for item
+ *
+ * @param {object} item - The item to be rendered
+ * @param {SharedData} sharedData - An object holding shared data
+ */
+ const renderItem = (item, sharedData) => {
+ const itemKey = item[itemIdKey];
+ const rowIndex = sharedData.rowIndex++;
+ const children = itemChildren(item);
+ const validChildren = Array.isArray(children) && children.length > 0;
+ const expandProps = validChildren && {
+ rowIndex,
+ isExpanded: isItemExpanded(itemKey),
+ onToggle: () => toggleExpanded(itemKey),
+ };
+
+ const selectProps = {
+ rowIndex,
+ onSelect: () => updateSelection(item),
+ isSelected: isItemSelected(item),
+ variant: isMultiple ? RowSelectVariant.checkbox : RowSelectVariant.radio
+ };
+
+ const renderChildren = () => {
+ if (!validChildren) return;
+
+ return children.map(item => renderItemChild(item, isItemExpanded(itemKey), sharedData));
+ };
+
+ // TODO: Add label to Tbody?
+ return (
+
+
+
+
+ { columns?.map((column, index) => (
+
+ {column.value(item)}
+
+ ))}
+
+ { renderChildren() }
+
+ );
+ };
+
+ // @see SharedData
+ const sharedData = { rowIndex: 0 };
+
+ const TableBody = () => items?.map(item => renderItem(item, sharedData));
+
+ return (
+
+ );
+}
diff --git a/web/src/components/core/ExpandableSelector.test.jsx b/web/src/components/core/ExpandableSelector.test.jsx
new file mode 100644
index 0000000000..f76d3a1e8e
--- /dev/null
+++ b/web/src/components/core/ExpandableSelector.test.jsx
@@ -0,0 +1,449 @@
+/*
+ * 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 { ExpandableSelector } from "~/components/core";
+
+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 sda1 = {
+ sid: "60",
+ isDrive: false,
+ type: "",
+ active: true,
+ name: "/dev/sda1",
+ size: 512,
+ recoverableSize: 128,
+ systems : [],
+ udevIds: [],
+ udevPaths: []
+};
+
+const sda2 = {
+ sid: "61",
+ isDrive: false,
+ type: "",
+ active: true,
+ name: "/dev/sda2",
+ size: 512,
+ recoverableSize: 0,
+ systems : [],
+ udevIds: [],
+ udevPaths: []
+};
+
+sda.partitionTable = {
+ type: "gpt",
+ partitions: [sda1, sda2],
+ unpartitionedSize: 512
+};
+
+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 lv1 = {
+ sid: "163",
+ name: "/dev/system/vg/lv1",
+ content: "Personal Data"
+};
+
+const vg = {
+ sid: "162",
+ type: "vg",
+ name: "/dev/system/vg",
+ lvs: [
+ lv1
+ ]
+};
+
+const columns = [
+ { name: "Device", value: (item) => item.name },
+ {
+ name: "Content",
+ value: (item) => {
+ if (item.isDrive) return item.systems.map((s, i) => {s}
);
+ if (item.type === "vg") return `${item.lvs.length} logical volume(s)`;
+
+ return item.content;
+ }
+ },
+ { name: "Size", value: (item) => item.size },
+];
+
+const onChangeFn = jest.fn();
+
+let props;
+const commonProps = {
+ columns,
+ items: [sda, sdb, vg],
+ itemIdKey: "sid",
+ initialExpandedKeys: [sda.sid, vg.sid],
+ itemChildren: (item) => (
+ item.isDrive ? item.partitionTable?.partitions : item.lvs
+ ),
+ onSelectionChange: onChangeFn,
+ "aria-label": "Device selector"
+};
+
+describe("ExpandableSelector", () => {
+ beforeAll(() => {
+ jest.spyOn(console, "error").mockImplementation();
+ });
+
+ afterAll(() => {
+ console.error.mockRestore();
+ });
+
+ beforeEach(() => {
+ props = { ...commonProps };
+ });
+
+ it("renders a table with given name", () => {
+ plainRender( );
+ screen.getByRole("grid", { name: "Device selector" });
+ });
+
+ it("renders the table headers", () => {
+ plainRender( );
+ const table = screen.getByRole("grid");
+ within(table).getByRole("columnheader", { name: "Device" });
+ within(table).getByRole("columnheader", { name: "Content" });
+ within(table).getByRole("columnheader", { name: "Size" });
+ });
+
+ it("renders a rowgroup per parent item", () => {
+ plainRender( );
+ const groups = screen.getAllByRole("rowgroup");
+ // NOTE: since has also the rowgroup role, we expect to found 4 in
+ // this example: 1 thead + 3 tbody (sda, sdb, vg)
+ expect(groups.length).toEqual(4);
+ });
+
+ it("renders a row per given item and found children", () => {
+ plainRender( );
+ const table = screen.getByRole("grid");
+ within(table).getByRole("row", { name: /dev\/sda 1024/ });
+ within(table).getByRole("row", { name: /dev\/sdb 2048/ });
+ within(table).getByRole("row", { name: /dev\/system\/vg 1 logical/ });
+ within(table).getByRole("row", { name: /dev\/sda1 512/ });
+ within(table).getByRole("row", { name: /dev\/sda2 512/ });
+ within(table).getByRole("row", { name: /Personal Data/ });
+ });
+
+ it("renders a expand toggler in items with children", () => {
+ plainRender( );
+ const table = screen.getByRole("grid");
+ const sdaRow = within(table).getByRole("row", { name: /dev\/sda 1024/ });
+ const sdbRow = within(table).getByRole("row", { name: /dev\/sdb 2048/ });
+ const lvRow = within(table).getByRole("row", { name: /dev\/system\/vg 1 logical/ });
+
+ within(sdaRow).getByRole("button", { name: "Details" });
+ within(lvRow).getByRole("button", { name: "Details" });
+ // `/dev/sdb` does not have children, toggler must not be there
+ const sdbChildrenToggler = within(sdbRow).queryByRole("button", { name: "Details" });
+ expect(sdbChildrenToggler).toBeNull();
+ });
+
+ it("renders as expanded items which value for `itemIdKey` is included in `initialExpandedKeys` prop", () => {
+ plainRender(
+
+ );
+ const table = screen.getByRole("grid");
+ within(table).getByRole("row", { name: /dev\/sda1 512/ });
+ within(table).getByRole("row", { name: /dev\/sda2 512/ });
+ });
+
+ it("keeps track of expanded items", async () => {
+ const { user } = plainRender(
+
+ );
+ const table = screen.getByRole("grid");
+ const sdaRow = within(table).getByRole("row", { name: /sda 1024/ });
+ const sdaToggler = within(sdaRow).getByRole("button", { name: "Details" });
+ const vgRow = within(table).getByRole("row", { name: /vg 1 logical/ });
+ const vgToggler = within(vgRow).getByRole("button", { name: "Details" });
+
+ within(table).getByRole("row", { name: /dev\/sda1 512/ });
+ within(table).getByRole("row", { name: /dev\/sda2 512/ });
+
+ await user.click(vgToggler);
+
+ within(table).getByRole("row", { name: /Personal Data/ });
+
+ await user.click(sdaToggler);
+ const sdaPartitionsRows = within(table).queryAllByRole("row", { name: /sda[d] 512/ });
+ expect(sdaPartitionsRows.length).toEqual(0);
+ });
+
+ it("uses 'id' as key when `itemIdKey` prop is not given", () => {
+ plainRender(
+
+ );
+
+ const table = screen.getByRole("grid");
+ // Since itemIdKey does not match the id used for the item, they are
+ // collapsed by default and their children are not visible
+ const sdaChild = within(table).queryByRole("row", { name: /dev\/sda1 512/ });
+ expect(sdaChild).toBeNull();
+ });
+
+ it("uses given `itemIdKey` as key", () => {
+ plainRender(
+
+ );
+
+ const table = screen.getByRole("grid");
+ // Since itemIdKey === "name", "/dev/sda" is properly mounted as expanded. Its
+ // children must be visible
+ const sdaChild = within(table).queryByRole("row", { name: /dev\/sda1 512/ });
+ expect(sdaChild).not.toBeNull();
+ });
+
+ describe("when `itemsSelected` is given", () => {
+ it("renders nothing as checked if value is an empty array", () => {
+ plainRender( );
+ const table = screen.getByRole("grid");
+ const selection = within(table).queryAllByRole("radio", { checked: true });
+ expect(selection.length).toEqual(0);
+ });
+
+ describe("but it isn't an array", () => {
+ it("outputs to console.error", () => {
+ plainRender( );
+ expect(console.error).toHaveBeenCalledWith(
+ expect.stringContaining("prop must be an array"),
+ "Whatever"
+ );
+ });
+
+ it("renders nothing as selected", () => {
+ plainRender( );
+ const table = screen.getByRole("grid");
+ const selection = within(table).queryAllByRole("radio", { checked: true });
+ expect(selection.length).toEqual(0);
+ });
+ });
+ });
+
+ describe("when mounted as single selector", () => {
+ describe.each([undefined, null, false])("because isMultiple={%s}", (isMultiple) => {
+ beforeEach(() => {
+ props = { ...props, isMultiple };
+ });
+
+ it("renders a radio per item row", () => {
+ plainRender( );
+ const table = screen.getByRole("grid");
+ const radios = within(table).getAllByRole("radio");
+ expect(radios.length).toEqual(6);
+ });
+
+ describe("but `itemSelectable` is given", () => {
+ it("renders a radio only for items for which it returns true", () => {
+ const itemSelectable = (item) => item.isDrive || item.type === "vg";
+ plainRender( );
+ const table = screen.getByRole("grid");
+ const radios = within(table).getAllByRole("radio");
+
+ // Expected only three radios
+ expect(radios.length).toEqual(3);
+
+ // Not in below items
+ const sda1Row = within(table).getByRole("row", { name: /dev\/sda1/ });
+ const sda2Row = within(table).getByRole("row", { name: /dev\/sda2/ });
+ const lv1Row = within(table).getByRole("row", { name: /lv1/ });
+ expect(within(sda1Row).queryAllByRole("radio")).toEqual([]);
+ expect(within(sda1Row).queryAllByRole("radio")).toEqual([]);
+ expect(within(sda1Row).queryAllByRole("radio")).toEqual([]);
+ });
+ });
+
+ describe("and `itemsSelected` is given", () => {
+ describe("and it is an array with just one item", () => {
+ it("renders it as checked", async () => {
+ plainRender( );
+ const table = screen.getByRole("grid");
+ const sda1Row = within(table).getByRole("row", { name: /dev\/sda1/ });
+ const selection = screen.getAllByRole("radio", { checked: true });
+ expect(selection.length).toEqual(1);
+ within(sda1Row).getByRole("radio", { checked: true });
+ });
+ });
+
+ describe("but it is an array with more than one item", () => {
+ it("outputs to console.error", () => {
+ plainRender( );
+ expect(console.error).toHaveBeenCalledWith(
+ expect.stringContaining("Using only the first element")
+ );
+ });
+
+ it("renders the first one as checked", async () => {
+ plainRender( );
+ const table = screen.getByRole("grid");
+ const selection = screen.getAllByRole("radio", { checked: true });
+ const sda1Row = within(table).getByRole("row", { name: /dev\/sda1/ });
+ const lv1Row = within(table).getByRole("row", { name: /Personal Data/ });
+ const lv1Radio = within(lv1Row).getByRole("radio");
+ within(sda1Row).getByRole("radio", { checked: true });
+ expect(lv1Radio).not.toHaveAttribute("checked", true);
+ expect(selection.length).toEqual(1);
+ });
+ });
+ });
+
+ describe("and user selects an already selected item", () => {
+ it("does not trigger the `onSelectionChange` callback", async () => {
+ const { user } = plainRender( );
+ const sda1row = screen.getByRole("row", { name: /dev\/sda1/ });
+ const sda1radio = within(sda1row).getByRole("radio");
+ await user.click(sda1radio);
+ expect(onChangeFn).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("and user selects a not selected item", () => {
+ it("calls the `onSelectionChange` callback with a collection holding only selected item", async () => {
+ const { user } = plainRender( );
+ const sda2row = screen.getByRole("row", { name: /dev\/sda2/ });
+ const sda2radio = within(sda2row).getByRole("radio");
+ await user.click(sda2radio);
+ expect(onChangeFn).toHaveBeenCalledWith([sda2]);
+ });
+ });
+ });
+ });
+
+ describe("when mounted as multiple selector", () => {
+ beforeEach(() => {
+ props = { ...props, isMultiple: true };
+ });
+
+ it("renders a checkbox per item row", () => {
+ plainRender( );
+ const table = screen.getByRole("grid");
+ const checkboxes = within(table).getAllByRole("checkbox");
+ expect(checkboxes.length).toEqual(6);
+ });
+
+ describe("but `itemSelectable` is given", () => {
+ it("renders a checkbox only for items for which it returns true", () => {
+ const itemSelectable = (item) => item.isDrive || item.type === "vg";
+ plainRender( );
+ const table = screen.getByRole("grid");
+ const checkboxes = within(table).getAllByRole("checkbox");
+
+ // Expected only three checkboxes
+ expect(checkboxes.length).toEqual(3);
+
+ // Not in below items
+ const sda1Row = within(table).getByRole("row", { name: /dev\/sda1/ });
+ const sda2Row = within(table).getByRole("row", { name: /dev\/sda2/ });
+ const lv1Row = within(table).getByRole("row", { name: /lv1/ });
+ expect(within(sda1Row).queryAllByRole("checkbox")).toEqual([]);
+ expect(within(sda1Row).queryAllByRole("checkbox")).toEqual([]);
+ expect(within(sda1Row).queryAllByRole("checkbox")).toEqual([]);
+ });
+ });
+
+ describe("and `itemsSelected` is given", () => {
+ it("renders given items as checked", async () => {
+ plainRender( );
+ const table = screen.getByRole("grid");
+ const selection = screen.getAllByRole("checkbox", { checked: true });
+ const sda1Row = within(table).getByRole("row", { name: /dev\/sda1/ });
+ const lv1Row = within(table).getByRole("row", { name: /Personal Data/ });
+ within(sda1Row).getByRole("checkbox", { checked: true });
+ within(lv1Row).getByRole("checkbox", { checked: true });
+ expect(selection.length).toEqual(2);
+ });
+ });
+
+ it("renders initially selected items given via `itemsSelected` prop", async () => {
+ plainRender( );
+ const table = screen.getByRole("grid");
+ const sda1Row = within(table).getByRole("row", { name: /dev\/sda1/ });
+ const lv1Row = within(table).getByRole("row", { name: /Personal Data/ });
+ const selection = screen.getAllByRole("checkbox", { checked: true });
+ expect(selection.length).toEqual(2);
+ [sda1Row, lv1Row].forEach(row => within(row).getByRole("checkbox", { checked: true }));
+ });
+
+ describe("and user selects an already selected item", () => {
+ it("triggers the `onSelectionChange` callback with a collection not including the item", async () => {
+ const { user } = plainRender( );
+ const sda1row = screen.getByRole("row", { name: /dev\/sda1/ });
+ const sda1radio = within(sda1row).getByRole("checkbox");
+ await user.click(sda1radio);
+ expect(onChangeFn).toHaveBeenCalledWith([sda2]);
+ });
+ });
+
+ describe("and user selects a not selected item", () => {
+ it("calls the `onSelectionChange` callback with a collection including the item", async () => {
+ const { user } = plainRender( );
+ const sda2row = screen.getByRole("row", { name: /dev\/sda2/ });
+ const sda2checkbox = within(sda2row).getByRole("checkbox");
+ await user.click(sda2checkbox);
+ expect(onChangeFn).toHaveBeenCalledWith([sda1, sda2]);
+ });
+ });
+ });
+});
diff --git a/web/src/components/core/SegmentedControl.jsx b/web/src/components/core/SegmentedControl.jsx
new file mode 100644
index 0000000000..a89c2a0f8d
--- /dev/null
+++ b/web/src/components/core/SegmentedControl.jsx
@@ -0,0 +1,66 @@
+/*
+ * 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";
+
+const defaultRenderLabel = (option) => option?.label;
+
+/**
+ * Renders given options as a segmented control
+ * @component
+ *
+ * @param {object} props
+ * @param {object[]} props.options=[] - A collection of object.
+ * @param {string} [props.optionIdKey="id"] - The key to look for the option id.
+ * @param {(object) => React.ReactElement} [props.renderLabel=(object) => string] - The method for rendering the option label.
+ * @param {(object) => object} [props.onClick] - The callback triggered when user clicks an option.
+ * @param {object} [props.selected] - The selected option
+ */
+export default function SegmentedControl({
+ options = [],
+ optionIdKey = "id",
+ renderLabel = defaultRenderLabel,
+ onClick = () => {},
+ selected,
+}) {
+ return (
+
+
+ { options.map((option, idx) => {
+ const optionId = option[optionIdKey];
+
+ return (
+
+ onClick(option)}
+ >
+ {renderLabel(option)}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web/src/components/core/SegmentedControl.test.jsx b/web/src/components/core/SegmentedControl.test.jsx
new file mode 100644
index 0000000000..ba86bc61bd
--- /dev/null
+++ b/web/src/components/core/SegmentedControl.test.jsx
@@ -0,0 +1,70 @@
+/*
+ * 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 { SegmentedControl } from "~/components/core";
+
+const selectOption = { label: "Select", longLabel: "Select item", description: "Select an existing item" };
+const createOption = { label: "Create", longLabel: "Create item", description: "Create a new item" };
+const options = [
+ selectOption, createOption
+];
+
+describe("SegmentedControl", () => {
+ it("renders each given option as a button", () => {
+ plainRender( );
+ screen.getByRole("button", { name: "Select" });
+ screen.getByRole("button", { name: "Create" });
+ });
+
+ it("uses renderLabel for rendering the button text", () => {
+ const onClick = jest.fn();
+ const { user } = plainRender(
+ option.longLabel } />
+ );
+
+ const buttonByLabel = screen.queryByRole("button", { name: "Select" });
+ expect(buttonByLabel).toBeNull();
+ screen.getByRole("button", { name: "Select item" });
+ });
+
+ it("sets proper aria-current value for each button", () => {
+ plainRender( );
+ const selectButton = screen.getByRole("button", { name: "Select" });
+ const createButton = screen.getByRole("button", { name: "Create" });
+ expect(selectButton).toHaveAttribute("aria-current", "false");
+ expect(createButton).toHaveAttribute("aria-current", "true");
+ });
+
+ it("triggers given onClick callback when user clicks an option", async () => {
+ const onClick = jest.fn();
+ const { user } = plainRender(
+
+ );
+ const selectButton = screen.getByRole("button", { name: "Select" });
+
+ await user.click(selectButton);
+
+ expect(onClick).toHaveBeenCalledWith(selectOption);
+ });
+});
diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js
index f6448c03d3..ee08ff6160 100644
--- a/web/src/components/core/index.js
+++ b/web/src/components/core/index.js
@@ -56,7 +56,10 @@ export { default as NumericTextInput } from "./NumericTextInput";
export { default as PasswordInput } from "./PasswordInput";
export { default as DevelopmentInfo } from "./DevelopmentInfo";
export { default as Selector } from "./Selector";
+export { default as ExpandableSelector } from "./ExpandableSelector";
export { default as OptionsPicker } from "./OptionsPicker";
export { default as Reminder } from "./Reminder";
export { default as Tag } from "./Tag";
export { default as TreeTable } from "./TreeTable";
+export { default as SegmentedControl } from "./SegmentedControl";
+export { default as ControlledPanels } from "./ControlledPanels";
diff --git a/web/src/components/storage/DeviceSelectionDialog.jsx b/web/src/components/storage/DeviceSelectionDialog.jsx
new file mode 100644
index 0000000000..d7889697a9
--- /dev/null
+++ b/web/src/components/storage/DeviceSelectionDialog.jsx
@@ -0,0 +1,156 @@
+/*
+ * 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 { sprintf } from "sprintf-js";
+import { _ } from "~/i18n";
+import { deviceChildren } from "~/components/storage/utils";
+import { ControlledPanels as Panels, Popup } from "~/components/core";
+import { DeviceSelectorTable } from "~/components/storage";
+
+const SELECT_DISK_ID = "select-disk";
+const CREATE_LVM_ID = "create-lvm";
+const SELECT_DISK_PANEL_ID = "panel-for-disk-selection";
+const CREATE_LVM_PANEL_ID = "panel-for-lvm-creation";
+const OPTIONS_NAME = "selection-mode";
+
+const baseDescription = _("All the file systems will be created as %s \
+ by default, although the location of each file system can be customized later \
+ if needed.");
+
+const Html = ({ children, ...props }) => (
+
+);
+
+/**
+ * TODO: Write a good component description.
+ *
+ * Renders a dialog that allows user select a device
+ * @component
+ *
+ * @param {object} props
+ * @param {object[]} [props.devices=[]] - The actions to perform in the system.
+ * @param {boolean} [props.isOpen=false] - Whether the dialog is visible or not.
+ * @param {function} props.onClose - Callback to execute when user closes the dialog
+ * @param {function} props.onConfirm - Callback to execute when user closes the dialog
+ */
+export default function DeviceSelectionDialog({ devices, isOpen, onClose, onConfirm, ...props }) {
+ const [mode, setMode] = useState(SELECT_DISK_ID);
+ const [disks, setDisks] = useState([]);
+ const [vgDevices, setVgDevices] = useState([]);
+
+ const onConfirmation = () => {
+ let settings;
+
+ switch (mode) {
+ case SELECT_DISK_ID:
+ settings = { lvm: false, devices: disks };
+ break;
+ case CREATE_LVM_ID:
+ settings = { lvm: true, vgDevices };
+ break;
+ }
+
+ console.log("Data to sent", settings);
+ console.log("Calling onConfirm", onConfirm);
+
+ typeof onConfirm === "function" && onConfirm(settings);
+ };
+
+ const onCancel = (data) => console.log("accept data", data);
+
+ const selectDiskMode = mode === SELECT_DISK_ID;
+ const createVgMode = mode === CREATE_LVM_ID;
+
+ return (
+
+
+
+ setMode(SELECT_DISK_ID)}
+ controls={SELECT_DISK_PANEL_ID}
+ >
+ {_("Select a disk")}
+
+ setMode(CREATE_LVM_ID)}
+ controls={CREATE_LVM_PANEL_ID}
+ >
+ {_("Create a LVM Volume Group")}
+
+
+
+
+
+ { sprintf(baseDescription, _("partitions in the selected device")) }
+
+
+ deviceChildren(d)}
+ itemSelectable={d => d.type === "disk" }
+ onSelectionChange={setDisks}
+ variant="compact"
+ />
+
+
+
+
+ { sprintf(baseDescription, _("logical volumes of a new LVM Volume Group")) }
+
+
+
+ {_("The Physical Volumes for the new Volume Group will be allocated in the selected devices.")}
+
+
+ deviceChildren(d)}
+ itemSelectable={d => d.isDrive }
+ onSelectionChange={setVgDevices}
+ variant="compact"
+ />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/storage/DeviceSelectorTable.jsx b/web/src/components/storage/DeviceSelectorTable.jsx
new file mode 100644
index 0000000000..ae8d2055f8
--- /dev/null
+++ b/web/src/components/storage/DeviceSelectorTable.jsx
@@ -0,0 +1,106 @@
+/*
+ * 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 { _ } from "~/i18n";
+import { sprintf } from "sprintf-js";
+import { deviceSize } from '~/components/storage/utils';
+import { Icon } from "~/components/layout";
+import { If, ExpandableSelector, Tag } from "~/components/core";
+
+/**
+ * @typedef {import ("~/client/storage").ProposalSettings} ProposalSettings
+ * @typedef {import ("~/client/storage").StorageDevice} StorageDevice
+ */
+
+const DeviceContent = ({ device }) => {
+ const PTable = () => {
+ if (device.partitionTable === undefined) return null;
+
+ const type = device.partitionTable.type.toUpperCase();
+ const numPartitions = device.partitionTable.partitions?.length;
+
+ // TRANSLATORS: disk partition info, %s is replaced by partition table
+ // type (MS-DOS or GPT), %d is the number of the partitions
+ const text = sprintf(_("%s with %d partitions"), type, numPartitions);
+
+ return (
+
+ {text}
+
+ );
+ };
+
+ const FilesystemLabel = () => {
+ const label = device.filesystem?.label;
+ if (label) return {label} ;
+ };
+
+ const Systems = () => {
+ if (device.systems.length === 0) return null;
+
+ const System = ({ system }) => {
+ const logo = /windows/i.test(system) ? "windows_logo" : "linux_logo";
+
+ return {system}
;
+ };
+
+ return device.systems.map((s, i) => );
+ };
+
+ const Description = () => {
+ if (device.partitionTable) return null;
+ if (!device.sid) return _("Unused space");
+
+ return {device.description}
;
+ };
+
+ const hasSystems = device.systems?.length > 0;
+
+ return (
+
+ );
+};
+
+const deviceColumns = [
+ { name: _("Device"), value: (item) => item.name },
+ { name: _("Content"), value: (item) => },
+ { name: _("Size"), value: (item) => deviceSize(item.size) }
+];
+
+export default function DeviceSelectorTable({ devices, selected, ...props }) {
+ return (
+
+ );
+}
diff --git a/web/src/components/storage/ProposalDeviceSection.jsx b/web/src/components/storage/ProposalDeviceSection.jsx
index e8a1793706..2f65a78e2f 100644
--- a/web/src/components/storage/ProposalDeviceSection.jsx
+++ b/web/src/components/storage/ProposalDeviceSection.jsx
@@ -24,15 +24,11 @@ import {
Button,
Form,
Skeleton,
- Switch,
- ToggleGroup, ToggleGroupItem,
- Tooltip
} from "@patternfly/react-core";
import { _ } from "~/i18n";
-import { Icon } from "~/components/layout";
import { If, Section, Popup } from "~/components/core";
-import { DeviceList, DeviceSelector } from "~/components/storage";
+import { DeviceSelector, DeviceSelectionDialog } from "~/components/storage";
import { deviceLabel } from '~/components/storage/utils';
import { noop } from "~/utils";
@@ -146,6 +142,8 @@ const InstallationDeviceField = ({
{_("Installation device")}
+ { /* FIXME: the new dialog we're working on, remove `={false}` to see it ;) */ }
+
- {_("Accept")}
-
-
-
-
- >
- );
-};
-
-/**
- * Form for configuring the system volume group.
- * @component
- *
- * @param {object} props
- * @param {string} props.id - Form ID.
- * @param {ProposalSettings} props.settings - Settings used for calculating a proposal.
- * @param {StorageDevice[]} [props.devices=[]] - Available storage devices.
- * @param {onSubmitFn} [props.onSubmit=noop] - On submit callback.
- * @param {onValidateFn} [props.onValidate=noop] - On validate callback.
- *
- * @callback onSubmitFn
- * @param {string[]} devices - Name of the selected devices.
- *
- * @callback onValidateFn
- * @param {boolean} valid
- */
-const LVMSettingsForm = ({
- id,
- settings,
- devices = [],
- onSubmit: onSubmitProp = noop,
- onValidate = noop
-}) => {
- const [vgDevices, setVgDevices] = useState(settings.systemVGDevices);
- const [isBootDeviceSelected, setIsBootDeviceSelected] = useState(settings.systemVGDevices.length === 0);
- const [editedDevices, setEditedDevices] = useState(false);
-
- const selectBootDevice = () => {
- setIsBootDeviceSelected(true);
- onValidate(true);
- };
-
- const selectCustomDevices = () => {
- setIsBootDeviceSelected(false);
- const { bootDevice } = settings;
- const customDevices = (vgDevices.length === 0 && !editedDevices) ? [bootDevice] : vgDevices;
- setVgDevices(customDevices);
- onValidate(customDevices.length > 0);
- };
-
- const onChangeDevices = (selection) => {
- const selectedDevices = devices.filter(d => selection.includes(d.sid)).map(d => d.name);
- setVgDevices(selectedDevices);
- setEditedDevices(true);
- onValidate(devices.length > 0);
- };
-
- const onSubmit = (e) => {
- e.preventDefault();
- const customDevices = isBootDeviceSelected ? [] : vgDevices;
- onSubmitProp(customDevices);
- };
-
- const BootDevice = () => {
- const bootDevice = devices.find(d => d.name === settings.bootDevice);
-
- // FIXME: In this case, should be a "readOnly" selector.
- return ;
- };
-
- return (
-
- );
-};
-
-/**
- * Allows to select LVM and configure the system volume group.
- * @component
- *
- * @param {object} props
- * @param {ProposalSettings} props.settings - Settings used for calculating a proposal.
- * @param {StorageDevice[]} [props.devices=[]] - Available storage devices.
- * @param {boolean} [props.isChecked=false] - Whether LVM is selected.
- * @param {boolean} [props.isLoading=false] - Whether to show the selector as loading.
- * @param {onChangeFn} [props.onChange=noop] - On change callback.
- *
- * @callback onChangeFn
- * @param {boolean} lvm
- */
-const LVMField = ({
- settings,
- devices = [],
- isChecked: isCheckedProp = false,
- isLoading = false,
- onChange: onChangeProp = noop
-}) => {
- const [isChecked, setIsChecked] = useState();
- const [isFormOpen, setIsFormOpen] = useState(false);
- const [isFormValid, setIsFormValid] = useState(true);
-
- const onChange = (_, value) => {
- setIsChecked(value);
- onChangeProp({ lvm: value, vgDevices: [] });
- };
-
- const openForm = () => setIsFormOpen(true);
-
- const closeForm = () => setIsFormOpen(false);
-
- const onValidateForm = (valid) => setIsFormValid(valid);
-
- const onSubmitForm = (vgDevices) => {
- closeForm();
- onChangeProp({ vgDevices });
- };
-
- useEffect(() => {
- setIsChecked(isCheckedProp);
- }, [isCheckedProp, setIsChecked]);
- const description = _("Configuration of the system volume group. All the file systems will be \
-created in a logical volume of the system volume group.");
-
- const LVMSettingsButton = () => {
- return (
-
-
-
-
-
- );
- };
-
- if (isLoading) return ;
-
- return (
-
-
-
} />
-
-
-
-
{_("Accept")}
-
+ >
);
};
@@ -405,14 +205,6 @@ export default function ProposalDeviceSection({
}
};
- const changeLVM = ({ lvm, vgDevices }) => {
- const settings = {};
- if (lvm !== undefined) settings.lvm = lvm;
- if (vgDevices !== undefined) settings.systemVGDevices = vgDevices;
-
- onChange(settings);
- };
-
const Description = () => (
-
);
}
diff --git a/web/src/components/storage/ProposalDeviceSection.test.jsx b/web/src/components/storage/ProposalDeviceSection.test.jsx
index e30db63e18..64c255596e 100644
--- a/web/src/components/storage/ProposalDeviceSection.test.jsx
+++ b/web/src/components/storage/ProposalDeviceSection.test.jsx
@@ -228,170 +228,4 @@ describe("ProposalDeviceSection", () => {
expect(props.onChange).not.toHaveBeenCalled();
});
});
-
- describe("LVM field", () => {
- describe("if LVM setting is not set yet", () => {
- beforeEach(() => {
- props.settings = {};
- });
-
- it("does not render the LVM switch", () => {
- plainRender( );
-
- expect(screen.queryByLabelText(/Use logical volume/)).toBeNull();
- });
- });
-
- describe("if LVM setting is set", () => {
- beforeEach(() => {
- props.settings = { lvm: false };
- });
-
- it("renders the LVM switch", () => {
- plainRender( );
-
- screen.getByRole("checkbox", { name: /Use logical volume/ });
- });
- });
-
- describe("if LVM is set to true", () => {
- beforeEach(() => {
- props.availableDevices = [vda, md0, md1];
- props.settings = { bootDevice: "/dev/vda", lvm: true, systemVGDevices: [] };
- props.onChange = jest.fn();
- });
-
- it("renders the LVM switch as selected", () => {
- plainRender( );
-
- const checkbox = screen.getByRole("checkbox", { name: /Use logical volume/ });
- expect(checkbox).toBeChecked();
- });
-
- it("renders a button for changing the LVM settings", () => {
- plainRender( );
-
- screen.getByRole("button", { name: /LVM settings/ });
- });
-
- it("changes the selection on click", async () => {
- const { user } = plainRender( );
-
- const checkbox = screen.getByRole("checkbox", { name: /Use logical volume/ });
- await user.click(checkbox);
-
- expect(checkbox).not.toBeChecked();
- expect(props.onChange).toHaveBeenCalled();
- });
-
- describe("and user clicks on LVM settings", () => {
- it("opens the LVM settings dialog", async () => {
- const { user } = plainRender( );
- const settingsButton = screen.getByRole("button", { name: /LVM settings/ });
-
- await user.click(settingsButton);
-
- const popup = await screen.findByRole("dialog");
- within(popup).getByText("System Volume Group");
- });
-
- it("allows selecting either installation device or custom devices", async () => {
- const { user } = plainRender( );
- const settingsButton = screen.getByRole("button", { name: /LVM settings/ });
-
- await user.click(settingsButton);
-
- const popup = await screen.findByRole("dialog");
- screen.getByText("System Volume Group");
-
- within(popup).getByRole("button", { name: "Installation device" });
- within(popup).getByRole("button", { name: "Custom devices" });
- });
-
- it("allows to set the installation device as system volume group", async () => {
- const { user } = plainRender( );
- const settingsButton = screen.getByRole("button", { name: /LVM settings/ });
-
- await user.click(settingsButton);
-
- const popup = await screen.findByRole("dialog");
- screen.getByText("System Volume Group");
-
- const bootDeviceButton = within(popup).getByRole("button", { name: "Installation device" });
- const customDevicesButton = within(popup).getByRole("button", { name: "Custom devices" });
- const acceptButton = within(popup).getByRole("button", { name: "Accept" });
-
- await user.click(customDevicesButton);
- await user.click(bootDeviceButton);
- await user.click(acceptButton);
-
- expect(props.onChange).toHaveBeenCalledWith(
- expect.objectContaining({ systemVGDevices: [] })
- );
- });
-
- it("allows customize the system volume group", async () => {
- const { user } = plainRender( );
- const settingsButton = screen.getByRole("button", { name: /LVM settings/ });
-
- await user.click(settingsButton);
-
- const popup = await screen.findByRole("dialog");
- screen.getByText("System Volume Group");
-
- const customDevicesButton = within(popup).getByRole("button", { name: "Custom devices" });
- const acceptButton = within(popup).getByRole("button", { name: "Accept" });
-
- await user.click(customDevicesButton);
-
- const vdaOption = within(popup).getByRole("row", { name: /vda/ });
- const md0Option = within(popup).getByRole("row", { name: /md0/ });
- const md1Option = within(popup).getByRole("row", { name: /md1/ });
-
- // unselect the boot devices
- await user.click(vdaOption);
-
- await user.click(md0Option);
- await user.click(md1Option);
-
- await user.click(acceptButton);
-
- expect(props.onChange).toHaveBeenCalledWith(
- expect.objectContaining({ systemVGDevices: ["/dev/md0", "/dev/md1"] })
- );
- });
- });
- });
-
- describe("if LVM is set to false", () => {
- beforeEach(() => {
- props.settings = { lvm: false };
- props.onChange = jest.fn();
- });
-
- it("renders the LVM switch as not selected", () => {
- plainRender( );
-
- const checkbox = screen.getByRole("checkbox", { name: /Use logical volume/ });
- expect(checkbox).not.toBeChecked();
- });
-
- it("does not render a button for changing the LVM settings", () => {
- plainRender( );
-
- const button = screen.queryByRole("button", { name: /LVM settings/ });
- expect(button).toBeNull();
- });
-
- it("changes the selection on click", async () => {
- const { user } = plainRender( );
-
- const checkbox = screen.getByRole("checkbox", { name: /Use logical volume/ });
- await user.click(checkbox);
-
- expect(checkbox).toBeChecked();
- expect(props.onChange).toHaveBeenCalled();
- });
- });
- });
});
diff --git a/web/src/components/storage/index.js b/web/src/components/storage/index.js
index 063d513dab..c9bc1810c5 100644
--- a/web/src/components/storage/index.js
+++ b/web/src/components/storage/index.js
@@ -36,3 +36,5 @@ export { default as ZFCPDiskForm } from "./ZFCPDiskForm";
export { default as ISCSIPage } from "./ISCSIPage";
export { DeviceList, DeviceSelector } from "./device-utils";
export { default as VolumeForm } from "./VolumeForm";
+export { default as DeviceSelectionDialog } from "./DeviceSelectionDialog";
+export { default as DeviceSelectorTable } from "./DeviceSelectorTable";