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 ( +
+ +
+ ); +}; + +/** + * 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 ( +
  • + +
  • + ); + })} +
+
+ ); +} 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 ( +
+ + } + else={} + /> +
+ ); +}; + +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 ( -
-
- {_("Devices for creating the volume group")} - - - - -
- } - else={ - vgDevices?.includes(d.name))} - devices={devices} - onChange={onChangeDevices} - /> - } - /> - - ); -}; - -/** - * 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";