diff --git a/web/package-lock.json b/web/package-lock.json index b86403d755..8dc09725bb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,13 +14,13 @@ "@patternfly/react-table": "^4.113.0", "core-js": "^3.21.1", "fast-sort": "^3.2.1", - "filesize": "^10.0.5", "ipaddr.js": "^2.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.3.0", "react-teleporter": "^3.1.0", - "regenerator-runtime": "^0.13.9" + "regenerator-runtime": "^0.13.9", + "xbytes": "^1.8.0" }, "devDependencies": { "@babel/core": "^7.5.4", @@ -8978,14 +8978,6 @@ "node": ">= 10" } }, - "node_modules/filesize": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.0.7.tgz", - "integrity": "sha512-iMRG7Qo9nayLoU3PNCiLizYtsy4W1ClrapeCwEgtiQelOAOuRJiw4QaLI+sSr8xr901dgHv+EYP2bCusGZgoiA==", - "engines": { - "node": ">= 10.4.0" - } - }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -17924,6 +17916,14 @@ } } }, + "node_modules/xbytes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/xbytes/-/xbytes-1.8.0.tgz", + "integrity": "sha512-o/8qiLr54dYJaX/OB47vvb6cfkqFCdoXXhQdzPc8pF7ulsBGOHRTjgFqVceaw+EcjX1fskToTdFHAUhoeZB6xg==", + "engines": { + "node": ">=1" + } + }, "node_modules/xdg-basedir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", @@ -24611,11 +24611,6 @@ "tslib": "^2.0.1" } }, - "filesize": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.0.7.tgz", - "integrity": "sha512-iMRG7Qo9nayLoU3PNCiLizYtsy4W1ClrapeCwEgtiQelOAOuRJiw4QaLI+sSr8xr901dgHv+EYP2bCusGZgoiA==" - }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -31156,6 +31151,11 @@ "dev": true, "requires": {} }, + "xbytes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/xbytes/-/xbytes-1.8.0.tgz", + "integrity": "sha512-o/8qiLr54dYJaX/OB47vvb6cfkqFCdoXXhQdzPc8pF7ulsBGOHRTjgFqVceaw+EcjX1fskToTdFHAUhoeZB6xg==" + }, "xdg-basedir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", diff --git a/web/package.json b/web/package.json index 68e5d6f063..e52990ec73 100644 --- a/web/package.json +++ b/web/package.json @@ -101,12 +101,12 @@ "@patternfly/react-table": "^4.113.0", "core-js": "^3.21.1", "fast-sort": "^3.2.1", - "filesize": "^10.0.5", "ipaddr.js": "^2.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.3.0", "react-teleporter": "^3.1.0", - "regenerator-runtime": "^0.13.9" + "regenerator-runtime": "^0.13.9", + "xbytes": "^1.8.0" } } diff --git a/web/package/cockpit-agama.changes b/web/package/cockpit-agama.changes index 24dbf538ae..b3186b19fd 100644 --- a/web/package/cockpit-agama.changes +++ b/web/package/cockpit-agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Fri Jun 9 09:38:05 UTC 2023 - David Diaz + +- Storage: allow setting the volume size (gh#openSUSE/agama#590). + +------------------------------------------------------------------- Wed May 24 11:01:24 UTC 2023 - David Diaz - UI: Ensure that blur and grayscale CSS filters are not applied to diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index 3943668d22..8592e76f7c 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -300,3 +300,42 @@ span.notification-mark[data-variant="sidebar"] { .pf-c-popover li + li { margin: 0; } + +.radio-group { + .pf-c-radio { + position: relative; + padding-block-end: var(--spacer-small); + padding-inline-end: var(--spacer-small); + + &.selected::after { + --arrow-size: var(--spacer-small, 10px); + + content:''; + position: absolute; + bottom: -1px; + left: 50%; + width: 0; + height: 0; + border-bottom: solid var(--arrow-size) var(--color-gray); + border-left: solid var(--arrow-size) transparent; + border-right: solid var(--arrow-size) transparent; + } + } +} + +.highlighted-live-region { + padding: 10px; + background: var(--color-gray); +} + +.size-input-group { + max-inline-size: 20ch; + + input { + text-align: end; + } + + select { + inline-size: 8ch; + } +} diff --git a/web/src/assets/styles/composition.scss b/web/src/assets/styles/composition.scss index e0bce54382..25c2548932 100644 --- a/web/src/assets/styles/composition.scss +++ b/web/src/assets/styles/composition.scss @@ -16,6 +16,10 @@ gap: var(--split-gutter); } +[data-items-alignment="start"] { + align-items: start; +} + .wrapped { flex-wrap: wrap; } diff --git a/web/src/components/core/NumericTextInput.jsx b/web/src/components/core/NumericTextInput.jsx new file mode 100644 index 0000000000..a2023ee414 --- /dev/null +++ b/web/src/components/core/NumericTextInput.jsx @@ -0,0 +1,62 @@ +/* + * Copyright (c) [2023] 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 { TextInput } from "@patternfly/react-core"; +import { noop } from "~/utils"; + +/** + * Callback function for notifying a valid input change + * + * @callback onChangeFn + * @param {string|number} the input value + * @return {void} + */ + +/** + * Helper component for having an input text limited to not signed numbers + * @component + * + * Based on {@link PF/TextInput https://www.patternfly.org/v4/components/text-input} + * + * @note It allows empty value too. + * + * @param {object} props + * @param {string|number} props.value - the input value + * @param {onChangeFn} props.onChange - the callback to be called when the entered value match the input pattern + * @param {object} props.textInputProps - @see {@link https://www.patternfly.org/v4/components/text-input/#textinput} + * + * @returns {ReactComponent} + */ +export default function NumericTextInput({ value = "", onChange = noop, ...textInputProps }) { + // NOTE: Using \d* instead of \d+ at the beginning to allow empty + const pattern = /^\d*\.?\d*$/; + + const handleOnChange = (value) => { + if (pattern.test(value)) { + onChange(value); + } + }; + + return ( + + ); +} diff --git a/web/src/components/core/NumericTextInput.test.jsx b/web/src/components/core/NumericTextInput.test.jsx new file mode 100644 index 0000000000..246d7867c6 --- /dev/null +++ b/web/src/components/core/NumericTextInput.test.jsx @@ -0,0 +1,71 @@ +/* + * Copyright (c) [2022-2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useState } from "react"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { NumericTextInput } from "~/components/core"; + +// Using a controlled component for testing the rendered result instead of testing if +// the given onChange callback is called. The former is more aligned with the +// React Testing Library principles, https://testing-library.com/docs/guiding-principles +const Input = ({ value: initialValue = "" }) => { + const [value, setValue] = useState(initialValue); + return ; +}; + +it("renders an input text control", () => { + plainRender(); + + const input = screen.getByRole("textbox", { name: "Test input" }); + expect(input).toHaveAttribute("type", "text"); +}); + +it("allows only digits and dot", async () => { + const { user } = plainRender(); + + const input = screen.getByRole("textbox", { name: "Test input" }); + expect(input).toHaveValue(""); + + await user.type(input, "-"); + expect(input).toHaveValue(""); + + await user.type(input, "+"); + expect(input).toHaveValue(""); + + await user.type(input, "1"); + expect(input).toHaveValue("1"); + + await user.type(input, ".5"); + expect(input).toHaveValue("1.5"); + + await user.type(input, " GiB"); + expect(input).toHaveValue("1.5"); +}); + +it("allows clearing the input (empty values)", async () => { + const { user } = plainRender(); + + const input = screen.getByRole("textbox", { name: "Test input" }); + expect(input).toHaveValue("120"); + await user.clear(input); + expect(input).toHaveValue(""); +}); diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js index 92f44ce589..a63536a0c8 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -52,3 +52,4 @@ export { default as Terminal } from "./Terminal"; export { default as Tip } from "./Tip"; export { default as ShowTerminalButton } from "./ShowTerminalButton"; export { default as NotificationMark } from "./NotificationMark"; +export { default as NumericTextInput } from "./NumericTextInput"; diff --git a/web/src/components/storage/ProposalVolumes.jsx b/web/src/components/storage/ProposalVolumes.jsx index c62b0bc080..55ee6b1ab6 100644 --- a/web/src/components/storage/ProposalVolumes.jsx +++ b/web/src/components/storage/ProposalVolumes.jsx @@ -22,16 +22,15 @@ import React, { useState } from "react"; import { Dropdown, DropdownToggle, DropdownItem, - Form, FormGroup, FormSelect, FormSelectOption, List, ListItem, Skeleton, - TextInput, Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core"; import { TableComposable, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; import { Em, If, Popup, RowActions, Tip } from '~/components/core'; import { Icon } from '~/components/layout'; +import { VolumeForm } from '~/components/storage'; import { deviceSize } from '~/components/storage/utils'; import { noop } from "~/utils"; @@ -62,91 +61,6 @@ const AutoCalculatedHint = (volume) => { ); }; -/** - * Form used for adding a new file system from a list of templates - * @component - * - * @param {object} props - * @param {string} props.id - Form ID - * @param {object[]} props.templates - Volume templates - * @param {onSubmitFn} props.onSubmit - Function to use for submitting a new volume - * - * @callback onSubmitFn - * @param {object} volume - * @return {void} - */ -const VolumeForm = ({ id, templates, onSubmit }) => { - const [volume, setVolume] = useState(templates[0]); - - const changeVolume = (mountPoint) => { - const volume = templates.find(t => t.mountPoint === mountPoint); - setVolume(volume); - }; - - const submitForm = (e) => { - e.preventDefault(); - onSubmit(volume); - }; - - const volumeOptions = templates.map((template, index) => ( - - )); - - return ( -
- - - {volumeOptions} - - - - - - - - - - - -
- ); -}; - /** * Button with general actions for the file systems * @component @@ -240,14 +154,30 @@ const GeneralActions = ({ templates, onAdd, onReset }) => { * @param {object} volume * @return {void} */ -const VolumeRow = ({ columns, volume, isLoading, onDelete }) => { +const VolumeRow = ({ columns, volume, isLoading, onEdit, onDelete }) => { + const [isFormOpen, setIsFormOpen] = useState(false); + + const openForm = () => setIsFormOpen(true); + + const closeForm = () => setIsFormOpen(false); + + const acceptForm = (volume) => { + closeForm(); + onEdit(volume); + }; + const SizeLimits = ({ volume }) => { - const limits = `${deviceSize(volume.minSize)} - ${deviceSize(volume.maxSize)}`; + const minSize = deviceSize(volume.minSize); + const maxSize = deviceSize(volume.maxSize); const isAuto = volume.adaptiveSizes && !volume.fixedSizeLimits; + let size = minSize; + if (minSize && maxSize && minSize !== maxSize) size = `${minSize} - ${maxSize}`; + if (maxSize === undefined) size = `At least ${minSize}`; + return (
- {limits} + {size} auto} />
); @@ -270,20 +200,24 @@ const VolumeRow = ({ columns, volume, isLoading, onDelete }) => { ); }; - const VolumeActions = ({ volume, onDelete }) => { + const VolumeActions = ({ volume, onEdit, onDelete }) => { const actions = () => { const actions = { delete: { title: "Delete", onClick: () => onDelete(volume), className: "danger-action" + }, + edit: { + title: "Edit", + onClick: () => onEdit(volume) } }; if (volume.optional) - return [actions.delete]; + return [actions.edit, actions.delete]; else - return []; + return [actions.edit]; }; const currentActions = actions(); @@ -302,17 +236,33 @@ const VolumeRow = ({ columns, volume, isLoading, onDelete }) => { } return ( - - {volume.mountPoint} -
- - - + + {volume.mountPoint} +
+ + + + + + + + - - + + Accept + + + + ); }; @@ -333,11 +283,18 @@ const VolumesTable = ({ volumes, isLoading, onVolumesChange }) => { const columns = { mountPoint: "At", details: "Details", - size: "Size limits", + size: "Size", actions: "Actions" }; const VolumesContent = ({ volumes, isLoading, onVolumesChange }) => { + const editVolume = (volume) => { + const index = volumes.findIndex(v => v.mountPoint === volume.mountPoint); + const newVolumes = [...volumes]; + newVolumes[index] = volume; + onVolumesChange(newVolumes); + }; + const deleteVolume = (volume) => { const newVolumes = volumes.filter(v => v.mountPoint !== volume.mountPoint); onVolumesChange(newVolumes); @@ -353,6 +310,7 @@ const VolumesTable = ({ volumes, isLoading, onVolumesChange }) => { columns={columns} volume={volume} isLoading={isLoading} + onEdit={editVolume} onDelete={deleteVolume} /> ); diff --git a/web/src/components/storage/ProposalVolumes.test.jsx b/web/src/components/storage/ProposalVolumes.test.jsx index 88c76888aa..117e8c80d0 100644 --- a/web/src/components/storage/ProposalVolumes.test.jsx +++ b/web/src/components/storage/ProposalVolumes.test.jsx @@ -46,13 +46,25 @@ const volumes = { fsType: "Btrfs", snapshots: true }, + swap: { + mountPoint: "swap", + optional: true, + deviceType: "partition", + encrypted: false, + minSize: 1024, + maxSize: 1024, + adaptiveSizes: false, + fixedSizeLimits: true, + fsType: "Swap", + snapshots: false + }, home: { mountPoint: "/home", optional: true, deviceType: "partition", encrypted: false, minSize: 1024, - maxSize: 2048, + maxSize: -1, adaptiveSizes: false, fixedSizeLimits: true, fsType: "XFS", @@ -133,7 +145,7 @@ it("allows to cancel if add action is used", async () => { describe("if there are volumes", () => { beforeEach(() => { - props.volumes = [volumes.root, volumes.home]; + props.volumes = [volumes.root, volumes.home, volumes.swap]; }); it("renders skeleton for each volume if loading", async () => { @@ -144,10 +156,10 @@ describe("if there are volumes", () => { const [, body] = await screen.findAllByRole("rowgroup"); const rows = within(body).getAllByRole("row"); - expect(rows.length).toEqual(2); + expect(rows.length).toEqual(3); const loadingRows = within(body).getAllByRole("row", { name: "PFSkeleton" }); - expect(loadingRows.length).toEqual(2); + expect(loadingRows.length).toEqual(3); }); it("renders the information for each volume", async () => { @@ -155,18 +167,19 @@ describe("if there are volumes", () => { const [, body] = await screen.findAllByRole("rowgroup"); - expect(within(body).queryAllByRole("row").length).toEqual(2); + expect(within(body).queryAllByRole("row").length).toEqual(3); within(body).getByRole("row", { name: "/ Btrfs partition with snapshots 1 KiB - 2 KiB" }); - within(body).getByRole("row", { name: "/home XFS partition 1 KiB - 2 KiB" }); + within(body).getByRole("row", { name: "/home XFS partition At least 1 KiB" }); + within(body).getByRole("row", { name: "swap Swap partition 1 KiB" }); }); - it("allows to delete the volume", async () => { + it("allows deleting the volume", async () => { props.onChange = jest.fn(); const { user } = plainRender(); const [, body] = await screen.findAllByRole("rowgroup"); - const row = within(body).getByRole("row", { name: "/home XFS partition 1 KiB - 2 KiB" }); + const row = within(body).getByRole("row", { name: "/home XFS partition At least 1 KiB" }); const actions = within(row).getByRole("button", { name: "Actions" }); await user.click(actions); const deleteAction = within(row).queryByRole("menuitem", { name: "Delete" }); @@ -174,6 +187,24 @@ describe("if there are volumes", () => { expect(props.onChange).toHaveBeenCalledWith(expect.not.arrayContaining([volumes.home])); }); + + it("allows editing the volume", async () => { + props.onChange = jest.fn(); + + const { user } = plainRender(); + + const [, body] = await screen.findAllByRole("rowgroup"); + const row = within(body).getByRole("row", { name: "/home XFS partition At least 1 KiB" }); + const actions = within(row).getByRole("button", { name: "Actions" }); + await user.click(actions); + const editAction = within(row).queryByRole("menuitem", { name: "Edit" }); + await user.click(editAction); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Edit file system"); + const mountPointSelector = within(popup).getByRole("combobox", { name: "Mount point" }); + expect(mountPointSelector).toHaveAttribute("disabled"); + }); }); describe("if there are not volumes", () => { diff --git a/web/src/components/storage/VolumeForm.jsx b/web/src/components/storage/VolumeForm.jsx new file mode 100644 index 0000000000..ac9dc27896 --- /dev/null +++ b/web/src/components/storage/VolumeForm.jsx @@ -0,0 +1,479 @@ +/* + * Copyright (c) [2023] 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, { useReducer } from "react"; + +import { + InputGroup, + Form, FormGroup, FormSelect, FormSelectOption, + Radio, + TextInput +} from "@patternfly/react-core"; + +import { If, NumericTextInput } from '~/components/core'; +import { DEFAULT_SIZE_UNIT, SIZE_METHODS, SIZE_UNITS, parseToBytes, splitSize } from '~/components/storage/utils'; + +/** + * Callback function for notifying a form input change + * + * @callback onChangeFn + * @param {object} an object with the changed input and its new value + * @return {void} + */ + +/** + * Form control for selecting a size unit + * @component + * + * Based on {@link PF/FormSelect https://www.patternfly.org/v4/components/form-select} + * + * @param {object} props + * @param {Array} props.units - a collection of size units + * @param {object} props.formSelectProps - @see {@link https://www.patternfly.org/v4/components/form-select#props} + * @returns {ReactComponent} + */ +const SizeUnitFormSelect = ({ units, ...formSelectProps }) => { + return ( + + { units.map(unit => ) } + + ); +}; + +/** + * Form control for selecting a mount point + * @component + * + * Based on {@link PF/FormSelect https://www.patternfly.org/v4/components/form-select} + * + * @param {object} props + * @param {Array} props.volumes - a collection of storage volumes + * @param {object} props.formSelectProps - @see {@link https://www.patternfly.org/v4/components/form-select#props} + * @returns {ReactComponent} + */ +const MountPointFormSelect = ({ volumes, ...formSelectProps }) => { + return ( + + { volumes.map(v => ) } + + ); +}; + +/** + * Widget for rendering the size option content when SIZE_UNITS.AUTO is selected + * @component + * + * @param {object} props + * @param {import(~/clients/storage).Volume} volume - a storage volume object + * @returns {ReactComponent} + */ +const SizeAuto = ({ volume }) => { + const conditions = []; + + if (volume.snapshotsAffectSizes) + conditions.push("the configuration of snapshots"); + + if (volume.sizeRelevantVolumes && volume.sizeRelevantVolumes.length > 0) + conditions.push(`the presence of the file system for ${volume.sizeRelevantVolumes.join(", ")}.`); + + const conditionsText = `The final size depends on ${conditions.join(" and ")}`; + + return ( + <> +

Automatically calculated size according to the selected product. {conditionsText}

+ + ); +}; + +/** + * Widget for rendering the size option content when SIZE_UNITS.MANUAL is selected + * @component + * + * @param {object} props + * @param {object} props.errors - the form errors + * @param {object} props.formData - the form data + * @param {onChangeFn} props.onChange - callback for notifying input changes + * + * @returns {ReactComponent} + */ +const SizeManual = ({ errors, formData, onChange }) => { + return ( +
+

+ Exact size for the file system. +

+ + + onChange({ size })} + validated={errors.size && 'error'} + /> + onChange({ sizeUnit })} + /> + + +
+ ); +}; + +/** + * Widget for rendering the size option content when SIZE_UNITS.RANGE is selected + * @component + * + * @param {object} props + * @param {object} props.errors - the form errors + * @param {object} props.formData - the form data + * @param {onChangeFn} props.onChange - callback for notifying input changes + * + * @returns {ReactComponent} + */ +const SizeRange = ({ errors, formData, onChange }) => { + return ( +
+

+ Limits for the file system size. The final size will be a value between the given minimum + and maximum sizes. If no maximum is given, then the file system will be as big as possible. +

+
+ + + onChange({ minSize })} + validated={errors.minSize && 'error'} + /> + onChange({ minSizeUnit })} + /> + + + + + onChange({ maxSize })} + + /> + onChange({ maxSizeUnit })} + /> + + +
+
+ ); +}; + +/** + * Widget for rendering the volume size options + * @component + * + * @param {object} props + * @param {object} props.errors - the form errors + * @param {object} props.formData - the form data + * @param {import(~/clients/storage).Volume} volume - the selected storage volume + * @param {onChangeFn} props.onChange - callback for notifying input changes + * + * @returns {ReactComponent} + */ +const SizeOptions = ({ errors, formData, volume, onChange }) => { + const { sizeMethod } = formData; + const sizeWidgetProps = { errors, formData, volume, onChange }; + + const sizeOptions = [SIZE_METHODS.MANUAL, SIZE_METHODS.RANGE]; + + if (volume.adaptiveSizes) sizeOptions.unshift(SIZE_METHODS.AUTO); + + return ( +
+
+ { sizeOptions.map((value) => { + const isSelected = sizeMethod === value; + + return ( + onChange({ sizeMethod: value })} + /> + ); + })} +
+ +
+ } /> + } /> + } /> +
+
+ ); +}; + +/** + * Creates a new storage volume object based on given params + * + * @param {import(~/clients/storage).Volume} volume - a storage volume + * @param {object} formData - data used to calculate the volume updates + * @returns {object} storage volume object + */ +const createUpdatedVolume = (volume, formData) => { + let updatedAttrs = {}; + const size = parseToBytes(`${formData.size} ${formData.sizeUnit}`); + const minSize = parseToBytes(`${formData.minSize} ${formData.minSizeUnit}`); + const maxSize = parseToBytes(`${formData.maxSize} ${formData.maxSizeUnit}`); + + switch (formData.sizeMethod) { + case SIZE_METHODS.AUTO: + updatedAttrs = { minSize: undefined, maxSize: undefined, fixedSizeLimits: false }; + break; + case SIZE_METHODS.MANUAL: + updatedAttrs = { minSize: size, maxSize: size, fixedSizeLimits: true }; + break; + case SIZE_METHODS.RANGE: + updatedAttrs = { minSize, maxSize: formData.maxSize ? maxSize : -1, fixedSizeLimits: true }; + break; + } + + return { ...volume, ...updatedAttrs }; +}; + +/** + * Form-related helper for guessing the size method for given volume + * + * @param {import(~/clients/storage).Volume} volume - a storage volume + * @return {string} corresponding size method + */ +const sizeMethodFor = (volume) => { + const { adaptiveSizes, fixedSizeLimits, minSize, maxSize } = volume; + + if (adaptiveSizes && !fixedSizeLimits) { + return SIZE_METHODS.AUTO; + } else if (minSize !== maxSize) { + return SIZE_METHODS.RANGE; + } else { + return SIZE_METHODS.MANUAL; + } +}; + +/** + * Form-related helper for preparing data based on given volume + * + * @param {import(~/clients/storage).Volume} volume - a storage volume object + * @return {object} an object ready to be used as a "form state" + */ +const prepareFormData = (volume) => { + const { size: minSize = "", unit: minSizeUnit = DEFAULT_SIZE_UNIT } = splitSize(volume.minSize); + const { size: maxSize = "", unit: maxSizeUnit = minSizeUnit || DEFAULT_SIZE_UNIT } = splitSize(volume.maxSize); + + return { + size: minSize, + sizeUnit: minSizeUnit, + minSize, + minSizeUnit, + maxSize, + maxSizeUnit, + sizeMethod: sizeMethodFor(volume), + mountPoint: volume.mountPoint + }; +}; + +/** + * Initializer function for the React#useReducer used in the {@link VolumesForm} + * + * @param {import(~/clients/storage).Volume} volume - a storage volume object + * @returns {object} a ready to use initial state + */ +const createInitialState = (volume) => { + return { + volume, + formData: prepareFormData(volume), + errors: {} + }; +}; + +/** + * The VolumeForm reducer + */ +const reducer = (state, action) => { + const { type, payload } = action; + + switch (type) { + case "CHANGE_VOLUME": { + return createInitialState(payload.volume); + } + + case "UPDATE_DATA": { + return { + ...state, + formData: { + ...state.formData, + ...payload + } + }; + } + + case "SET_ERRORS": { + return { ...state, errors: payload }; + } + + default: { + return state; + } + } +}; + +/** + * Form used for adding a new file system from a list of templates + * @component + * + * @note VolumeForm does not provide a submit button. It is the consumer's + * responsibility to provide both: the button for triggering the submission by + * using the form id and the callback function used to perform the submission + * once the form has been validated. + * + * @param {object} props + * @param {string} props.id - Form ID + * @param {Array} props.volumes - a collection of storage volumes + * @param {onSubmitFn} props.onSubmit - Function to use for submitting a new volume + * + * @callback onSubmitFn + * @param {import(~/clients/storage).Volume} volume - a storage volume object + * @return {void} + */ +export default function VolumeForm({ id, volume: currentVolume, templates = [], onSubmit }) { + const [state, dispatch] = useReducer(reducer, currentVolume || templates[0], createInitialState); + + const changeVolume = (mountPoint) => { + const volume = templates.find(t => t.mountPoint === mountPoint); + dispatch({ type: "CHANGE_VOLUME", payload: { volume } }); + }; + + const updateData = (data) => dispatch({ type: "UPDATE_DATA", payload: data }); + + const validateVolumeSize = (sizeMethod, volume) => { + const errors = {}; + const { minSize, maxSize } = volume; + + switch (sizeMethod) { + case SIZE_METHODS.AUTO: + break; + case SIZE_METHODS.MANUAL: + if (!minSize) { + errors.size = "A size value is required"; + } + break; + case SIZE_METHODS.RANGE: + if (!minSize) { + errors.minSize = "Minimum size is required"; + } + + if (maxSize !== -1 && maxSize <= minSize) { + errors.maxSize = "Maximum must be greater than minimum"; + } + break; + } + + return errors; + }; + + const submitForm = (e) => { + e.preventDefault(); + const { volume: originalVolume, formData } = state; + const volume = createUpdatedVolume(originalVolume, formData); + const errors = validateVolumeSize(formData.sizeMethod, volume); + + dispatch({ type: "SET_ERRORS", payload: errors }); + + if (!Object.keys(errors).length) onSubmit(volume); + }; + + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/web/src/components/storage/VolumeForm.test.jsx b/web/src/components/storage/VolumeForm.test.jsx new file mode 100644 index 0000000000..d6e3ddf9ec --- /dev/null +++ b/web/src/components/storage/VolumeForm.test.jsx @@ -0,0 +1,278 @@ +/* + * Copyright (c) [2022-2023] 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 { DEFAULT_SIZE_UNIT, parseToBytes } from "~/components/storage/utils"; +import { VolumeForm } from "~/components/storage"; + +const volumes = { + root: { + mountPoint: "/", + optional: false, + deviceType: "partition", + encrypted: false, + minSize: 1024, + maxSize: 2048, + adaptiveSizes: true, + fixedSizeLimits: true, + fsType: "Btrfs", + snapshots: true + }, + swap: { + mountPoint: "swap", + optional: true, + deviceType: "partition", + encrypted: false, + minSize: 1024, + maxSize: 1024, + adaptiveSizes: false, + fixedSizeLimits: true, + fsType: "Swap", + snapshots: false + }, + home: { + mountPoint: "/home", + optional: true, + deviceType: "partition", + encrypted: false, + minSize: 1024, + maxSize: -1, + adaptiveSizes: false, + fixedSizeLimits: true, + fsType: "XFS", + snapshots: false + } +}; + +const onSubmitFn = jest.fn(); + +// TL;DR, the form does not provide a submit button by itself. +// Refers the VolumeForm documentation. +const VolumeFormWrapper = ({ volume, onSubmit }) => { + return ( + <> + + + + ); +}; + +let props; + +beforeEach(() => { + props = { templates: [volumes.root, volumes.home, volumes.swap] }; +}); + +it("renders a control for displaying/selecting the mount point", () => { + plainRender(); + + screen.getByRole("combobox", { name: "Mount point" }); +}); + +it("renders a disabled control for displaying the file system type", () => { + plainRender(); + + const fsTypeInput = screen.getByRole("textbox", { name: "File system type" }); + expect(fsTypeInput).toBeDisabled(); +}); + +it("renders controls for setting the desired size", () => { + plainRender(); + + screen.getByRole("radio", { name: "Auto" }); + screen.getByRole("radio", { name: "Manual" }); + screen.getByRole("radio", { name: "Range" }); +}); + +it("uses the default size unit when min size unit is missing", () => { + plainRender(); + + const maxSizeUnitSelector = screen.getByRole("combobox", { name: "Max size unit" }); + expect(maxSizeUnitSelector).toHaveValue(DEFAULT_SIZE_UNIT); +}); + +it("uses the min size unit as max size unit when it is missing", () => { + plainRender(); + + const maxSizeUnitSelector = screen.getByRole("combobox", { name: "Max size unit" }); + expect(maxSizeUnitSelector).toHaveValue("TiB"); +}); + +it("renders the 'Auto' size option only when a volume with 'adaptive sizes' is selected", async () => { + const { user } = plainRender(); + + // We know that first volume (root in this example) is selected. And we know + // that it's configured for allowing adaptive sizes too. + screen.getByRole("radio", { name: "Auto" }); + + const mountPointSelector = screen.getByRole("combobox", { name: "Mount point" }); + const homeVolumeOption = screen.getByRole("option", { name: "/home" }); + + // And we know that /home volume is not set to allow adaptive sizes. Thus, + // let's select it. + await user.selectOptions(mountPointSelector, homeVolumeOption); + + const autoSizeOption = screen.queryByRole("radio", { name: "Auto" }); + expect(autoSizeOption).toBeNull(); +}); + +it("calls the onSubmit callback with resulting volume when the form is submitted", async () => { + const { user } = plainRender(); + const submitForm = screen.getByRole("button", { name: "Submit VolumeForm" }); + const rangeSize = screen.getByRole("radio", { name: "Range" }); + + await user.click(rangeSize); + + const minSizeInput = screen.getByRole("textbox", { name: "Minimum desired size" }); + const minSizeUnitSelector = screen.getByRole("combobox", { name: "Min size unit" }); + const minSizeGiBUnit = within(minSizeUnitSelector).getByRole("option", { name: "GiB" }); + const maxSizeInput = screen.getByRole("textbox", { name: "Maximum desired size" }); + const maxSizeUnitSelector = screen.getByRole("combobox", { name: "Max size unit" }); + const maxSizeGiBUnit = within(maxSizeUnitSelector).getByRole("option", { name: "GiB" }); + + await user.clear(minSizeInput); + await user.type(minSizeInput, "10"); + await user.selectOptions(minSizeUnitSelector, minSizeGiBUnit); + await user.clear(maxSizeInput); + await user.type(maxSizeInput, "25"); + await user.selectOptions(maxSizeUnitSelector, maxSizeGiBUnit); + await user.click(submitForm); + + expect(onSubmitFn).toHaveBeenCalledWith({ + ...volumes.root, minSize: parseToBytes("10 GiB"), maxSize: parseToBytes("25 GiB") + }); +}); + +describe("size validations", () => { + describe("when 'Manual' size is selected", () => { + beforeEach(() => { props.volume = volumes.home }); + + it("renders an error when size is not given", async () => { + const { user } = plainRender(); + + const submitForm = screen.getByRole("button", { name: "Submit VolumeForm" }); + const manualSize = screen.getByRole("radio", { name: "Manual" }); + await user.click(manualSize); + + const sizeInput = screen.getByRole("textbox", { name: "Desired size" }); + await user.clear(sizeInput); + await user.click(submitForm); + screen.getByText("A size value is required"); + + await user.type(sizeInput, "10"); + await user.click(submitForm); + expect(screen.queryByText("A size value is required")).toBeNull(); + }); + }); + + describe("when 'Range' size is selected", () => { + beforeEach(() => { props.volume = volumes.home }); + + it("renders an error when min size is not given", async () => { + const { user } = plainRender(); + + const submitForm = screen.getByRole("button", { name: "Submit VolumeForm" }); + const rangeSize = screen.getByRole("radio", { name: "Range" }); + await user.click(rangeSize); + + const minSizeInput = screen.getByRole("textbox", { name: "Minimum desired size" }); + + await user.clear(minSizeInput); + await user.click(submitForm); + screen.getByText("Minimum size is required"); + + await user.type(minSizeInput, "10"); + await user.click(submitForm); + expect(screen.queryByText("Minimum size is required")).toBeNull(); + }); + + it("renders an error when max size is smaller than or equal to min size", async () => { + // Let's start the test without predefined sizes + const volume = { ...volumes.home, minSize: "", maxSize: "" }; + const { user } = plainRender(); + + const submitForm = screen.getByRole("button", { name: "Submit VolumeForm" }); + const rangeSize = screen.getByRole("radio", { name: "Range" }); + await user.click(rangeSize); + + const minSizeInput = screen.getByRole("textbox", { name: "Minimum desired size" }); + const minSizeUnitSelector = screen.getByRole("combobox", { name: "Min size unit" }); + const minSizeMiBUnit = within(minSizeUnitSelector).getByRole("option", { name: "MiB" }); + const maxSizeInput = screen.getByRole("textbox", { name: "Maximum desired size" }); + const maxSizeUnitSelector = screen.getByRole("combobox", { name: "Max size unit" }); + const maxSizeGiBUnit = within(maxSizeUnitSelector).getByRole("option", { name: "GiB" }); + const maxSizeMiBUnit = within(maxSizeUnitSelector).getByRole("option", { name: "MiB" }); + + // Max (11 GiB) > Min (10 GiB) BEFORE changing any unit size + await user.clear(minSizeInput); + await user.type(minSizeInput, "10"); + await user.clear(maxSizeInput); + await user.type(maxSizeInput, "11"); + await user.click(submitForm); + expect(screen.queryByText("Maximum must be greater than minimum")).toBeNull(); + + // Max (10 GiB) === Min (10 GiB) + await user.clear(maxSizeInput); + await user.type(maxSizeInput, "10"); + await user.click(submitForm); + screen.getByText("Maximum must be greater than minimum"); + + // Max (10 MiB) < Min (10 GiB) because choosing a lower size unit + await user.selectOptions(maxSizeUnitSelector, maxSizeMiBUnit); + await user.click(submitForm); + screen.getByText("Maximum must be greater than minimum"); + + // Max (9 MiB) < Min (10 MiB) because choosing a lower size + await user.selectOptions(minSizeUnitSelector, minSizeMiBUnit); + await user.clear(maxSizeInput); + await user.type(maxSizeInput, "9"); + await user.click(submitForm); + screen.getByText("Maximum must be greater than minimum"); + + // Max (11 MiB) > Min (10 MiB) + await user.clear(maxSizeInput); + await user.type(maxSizeInput, "11"); + await user.selectOptions(maxSizeUnitSelector, maxSizeGiBUnit); + }); + }); +}); + +describe("when editing a new volume", () => { + beforeEach(() => { props.volume = volumes.root }); + + it("renders the mount point selector as disabled", () => { + plainRender(); + + const mountPointSelector = screen.getByRole("combobox", { name: "Mount point" }); + expect(mountPointSelector).toBeDisabled(); + }); +}); + +describe("when adding a new volume", () => { + it("renders the mount point selector as enabled", () => { + plainRender(); + + const mountPointSelector = screen.getByRole("combobox", { name: "Mount point" }); + expect(mountPointSelector).toBeEnabled(); + }); +}); diff --git a/web/src/components/storage/index.js b/web/src/components/storage/index.js index fd318e4028..b63a51067b 100644 --- a/web/src/components/storage/index.js +++ b/web/src/components/storage/index.js @@ -29,3 +29,4 @@ export { default as DASDTable } from "./DASDTable"; export { default as DASDFormatProgress } from "./DASDFormatProgress"; export { default as ISCSIPage } from "./ISCSIPage"; export { default as DeviceSelector } from "./DeviceSelector"; +export { default as VolumeForm } from "./VolumeForm"; diff --git a/web/src/components/storage/utils.js b/web/src/components/storage/utils.js index 1520152a0b..3ff234a513 100644 --- a/web/src/components/storage/utils.js +++ b/web/src/components/storage/utils.js @@ -19,9 +19,61 @@ * find current contact information at www.suse.com. */ -// cspell:ignore filesize +// cspell:ignore xbytes -import { filesize } from "filesize"; +import xbytes from "xbytes"; + +/** + * @typedef {Object} SizeObject + * + * @note undefined for either property means unknown + * + * @property {number|undefined} size - The "amount" of size (10, 128, ...) + * @property {string|undefined} unit - The size unit (MiB, GiB, ...) + */ + +const SIZE_METHODS = Object.freeze({ + AUTO: "auto", + MANUAL: "manual", + RANGE: "range" +}); + +const SIZE_UNITS = Object.freeze({ + K: "KiB", + M: "MiB", + G: "GiB", + T: "TiB", + P: "PiB", +}); + +const DEFAULT_SIZE_UNIT = "GiB"; + +/** + * Convenience method for generating a size object based on given input + * + * It split given input when a string is given or the result of converting the + * input otherwise. Note, however, that -1 number will treated as empty string + * since it means nothing for Agama UI although it represents the "unlimited" + * size in the backend. + * + * @param {number|string|undefined} size + * @returns {SizeObject} + */ +const splitSize = (size) => { + // From D-Bus, maxSize comes as -1 when set as "unlimited", but for Agama UI + // it means "leave it empty" + const sanitizedSize = size !== -1 ? size : ""; + const parsedSize = typeof sanitizedSize === "string" ? sanitizedSize : xbytes(sanitizedSize, { iec: true }); + const [qty, unit] = parsedSize.split(" "); + // `Number` will remove trailing zeroes; + // parseFloat ensures Number does not transform "" into 0. + const sanitizedQty = Number(parseFloat(qty)); + + return { + unit, + size: isNaN(sanitizedQty) ? undefined : sanitizedQty + }; +}; /** * Generates a disk size representation @@ -29,18 +81,48 @@ import { filesize } from "filesize"; * * @example * deviceSize(1024) - * // returns "1 kiB" + * // returns "1 KiB" * * deviceSize(-1) - * // returns "Unlimited" + * // returns undefined * * @param {number} size - Number of bytes. The value -1 represents an unlimited size. - * @returns {string} + * @returns {string|undefined} */ const deviceSize = (size) => { - if (size === -1) return "Unlimited"; + if (size === -1) return undefined; + + // Sadly, we cannot returns directly the xbytes(size, { iec: true }) because + // it does not have an option for dropping/ignoring trailing zeroes and we do + // not want to render them. + const result = splitSize(size); + return `${Number(result.size)} ${result.unit}`; +}; + +/** + * Returns the equivalent in bytes resulting from parsing given input + * @function + * + * @example + * parseToBytes(1024) + * // returns 1024 + * + * parseToBytes("1 KiB") + * // returns 1024 + * + * parseToBytes("") + * // returns 0 + * + * @param {string|number} size + * @returns {number} + */ +const parseToBytes = (size) => { + if (!size || size === undefined || size === "") return 0; + + const value = xbytes.parseSize(size, { iec: true }) || parseInt(size); - return filesize(size, { base: 2 }); + // Avoid decimals resulting from the conversion. D-Bus iface only accepts integer + return Math.trunc(value); }; /** @@ -56,4 +138,12 @@ const deviceLabel = (device) => { return size ? `${name}, ${deviceSize(size)}` : name; }; -export { deviceSize, deviceLabel }; +export { + DEFAULT_SIZE_UNIT, + SIZE_METHODS, + SIZE_UNITS, + deviceLabel, + deviceSize, + parseToBytes, + splitSize, +}; diff --git a/web/src/components/storage/utils.test.js b/web/src/components/storage/utils.test.js index 1e272f1fb2..e3101713ec 100644 --- a/web/src/components/storage/utils.test.js +++ b/web/src/components/storage/utils.test.js @@ -19,12 +19,12 @@ * find current contact information at www.suse.com. */ -import { deviceSize, deviceLabel } from "./utils"; +import { deviceSize, deviceLabel, parseToBytes, splitSize } from "./utils"; describe("deviceSize", () => { - it("returns unlimited is size is -1", () => { + it("returns undefined is size is -1", () => { const result = deviceSize(-1); - expect(result).toEqual("Unlimited"); + expect(result).toBeUndefined(); }); it("returns the size with units", () => { @@ -44,3 +44,52 @@ describe("deviceLabel", () => { expect(result).toEqual("/dev/sda"); }); }); + +describe("parseToBytes", () => { + it("returns bytes from given input", () => { + expect(parseToBytes(1024)).toEqual(1024); + expect(parseToBytes("1024")).toEqual(1024); + expect(parseToBytes("1 KiB")).toEqual(1024); + expect(parseToBytes("2 MiB")).toEqual(2097152); + }); + + it("returns 0 if given input is null, undefined, or empty string", () => { + expect(parseToBytes(null)).toEqual(0); + expect(parseToBytes(undefined)).toEqual(0); + expect(parseToBytes("")).toEqual(0); + }); + + it("does not include decimal part of resulting conversion", () => { + expect(parseToBytes("1024.32 KiB")).toEqual(1048903); // Not 1048903.68 + }); +}); + +describe("splitSize", () => { + it("returns a size object with size and unit from given input", () => { + expect(splitSize("2048 KiB")).toEqual({ size: 2048, unit: "KiB" }); + }); + + it("returns a size object with the result of converting the input when no string is given", () => { + expect(splitSize(1000)).toEqual({ size: 1000, unit: "B" }); + expect(splitSize(1024)).toEqual({ size: 1, unit: "KiB" }); + expect(splitSize(1048576)).toEqual({ size: 1, unit: "MiB" }); + expect(splitSize(undefined)).toEqual({ size: 0, unit: "B" }); + expect(splitSize(null)).toEqual({ size: 0, unit: "B" }); + }); + + it("returns a size object with unknown unit when a string without unit is given", () => { + expect(splitSize("30")).toEqual({ size: 30, unit: undefined }); + }); + + it("returns an 'empty' size object when -1 is given", () => { + expect(splitSize(-1)).toEqual({ size: undefined, unit: undefined }); + }); + + it("returns an 'empty' size object when an unexpected string is given", () => { + expect(splitSize("GiB")).toEqual({ size: undefined, unit: undefined }); + }); + + it("returns an 'empty' size object when empty string is given", () => { + expect(splitSize("")).toEqual({ size: undefined, unit: undefined }); + }); +});