From 12f8b6508e295c7287440d8dfda93b2e6a48383b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 15 Jan 2025 18:23:49 +0000 Subject: [PATCH 1/2] fix(web): drop no longer needed storage components Since they does not met requirements of the new storage interface. --- .../storage/BootConfigField.test.tsx | 80 -- .../components/storage/BootConfigField.tsx | 95 -- .../storage/InstallationDeviceField.test.tsx | 219 ----- .../storage/InstallationDeviceField.tsx | 113 --- .../storage/InvalidMaxSizeError.tsx | 46 - .../storage/PartitionsField.test.tsx | 488 ---------- .../components/storage/PartitionsField.tsx | 848 ------------------ .../components/storage/ProposalPage.test.tsx | 1 - .../storage/ProposalSettingsSection.test.tsx | 75 -- .../storage/ProposalSettingsSection.tsx | 141 --- .../storage/SnapshotsField.test.tsx | 70 -- web/src/components/storage/SnapshotsField.tsx | 74 -- .../components/storage/VolumeDialog.test.tsx | 428 --------- web/src/components/storage/VolumeDialog.tsx | 647 ------------- .../components/storage/VolumeFields.test.tsx | 191 ---- web/src/components/storage/VolumeFields.tsx | 554 ------------ .../storage/VolumeLocationDialog.test.tsx | 237 ----- .../storage/VolumeLocationDialog.tsx | 224 ----- .../storage/VolumeLocationSelectorTable.tsx | 133 --- web/src/components/storage/index.ts | 1 - 20 files changed, 4665 deletions(-) delete mode 100644 web/src/components/storage/BootConfigField.test.tsx delete mode 100644 web/src/components/storage/BootConfigField.tsx delete mode 100644 web/src/components/storage/InstallationDeviceField.test.tsx delete mode 100644 web/src/components/storage/InstallationDeviceField.tsx delete mode 100644 web/src/components/storage/InvalidMaxSizeError.tsx delete mode 100644 web/src/components/storage/PartitionsField.test.tsx delete mode 100644 web/src/components/storage/PartitionsField.tsx delete mode 100644 web/src/components/storage/ProposalSettingsSection.test.tsx delete mode 100644 web/src/components/storage/ProposalSettingsSection.tsx delete mode 100644 web/src/components/storage/SnapshotsField.test.tsx delete mode 100644 web/src/components/storage/SnapshotsField.tsx delete mode 100644 web/src/components/storage/VolumeDialog.test.tsx delete mode 100644 web/src/components/storage/VolumeDialog.tsx delete mode 100644 web/src/components/storage/VolumeFields.test.tsx delete mode 100644 web/src/components/storage/VolumeFields.tsx delete mode 100644 web/src/components/storage/VolumeLocationDialog.test.tsx delete mode 100644 web/src/components/storage/VolumeLocationDialog.tsx delete mode 100644 web/src/components/storage/VolumeLocationSelectorTable.tsx diff --git a/web/src/components/storage/BootConfigField.test.tsx b/web/src/components/storage/BootConfigField.test.tsx deleted file mode 100644 index 50958eb633..0000000000 --- a/web/src/components/storage/BootConfigField.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 BootConfigField, { BootConfigFieldProps } from "~/components/storage/BootConfigField"; -import { StorageDevice } from "~/types/storage"; - -const sda: StorageDevice = { - sid: 59, - description: "A fake disk for testing", - isDrive: true, - type: "disk", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - busId: "", - transport: "usb", - dellBOSS: false, - sdCard: true, - active: true, - name: "/dev/sda", - size: 1024, - shrinking: { unsupported: ["Resizing is not supported"] }, - 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 props: BootConfigFieldProps = { - configureBoot: false, - bootDevice: undefined, - defaultBootDevice: undefined, - availableDevices: [sda], - isLoading: false, -}; - -describe.skip("BootConfigField", () => { - describe("when installation is set for not configuring boot", () => { - it("renders a text warning about it", () => { - plainRender(); - screen.getByText(/will not configure partitions/); - }); - }); - - describe("when installation is set for automatically configuring boot", () => { - it("renders a text reporting about it", () => { - plainRender(); - screen.getByText(/configure partitions for booting at the installation disk/); - }); - }); - - describe("when installation is set for configuring boot at specific device", () => { - it("renders a text reporting about it", () => { - plainRender(); - screen.getByText(/partitions for booting at \/dev\/sda/); - }); - }); -}); diff --git a/web/src/components/storage/BootConfigField.tsx b/web/src/components/storage/BootConfigField.tsx deleted file mode 100644 index b275592d55..0000000000 --- a/web/src/components/storage/BootConfigField.tsx +++ /dev/null @@ -1,95 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 { Link as RouterLink } from "react-router-dom"; -import { Skeleton } from "@patternfly/react-core"; -import { _ } from "~/i18n"; -import { sprintf } from "sprintf-js"; -import { deviceLabel } from "~/components/storage/utils"; -import { Icon } from "~/components/layout"; -import { StorageDevice } from "~/types/storage"; -import { STORAGE as PATHS } from "~/routes/paths"; - -/** - * Internal component for building the link that navigates to selector - * - * @param props - * @param [props.isBold=false] - Whether text should be wrapped by . - */ -const Link = ({ isBold = false }: { isBold?: boolean }) => { - const text = _("Change boot options"); - - return {isBold ? {text} : text}; -}; - -export type BootConfig = { - configureBoot: boolean; - bootDevice: StorageDevice; -}; - -export type BootConfigFieldProps = { - configureBoot: boolean; - bootDevice?: StorageDevice; - defaultBootDevice?: StorageDevice; - availableDevices: StorageDevice[]; - isLoading: boolean; -}; - -/** - * Summarizes how the system will boot. - * @component - */ -export default function BootConfigField({ - configureBoot, - bootDevice, - isLoading, -}: BootConfigFieldProps) { - if (isLoading && configureBoot === undefined) { - return ; - } - - let value: React.ReactNode; - - if (!configureBoot) { - value = ( - <> - {" "} - {_("Installation will not configure partitions for booting.")} - - ); - } else if (!bootDevice) { - value = _("Installation will configure partitions for booting at the installation disk."); - } else { - // TRANSLATORS: %s is the disk used to configure the boot-related partitions (eg. "/dev/sda, 80 GiB) - value = sprintf( - _("Installation will configure partitions for booting at %s."), - deviceLabel(bootDevice), - ); - } - - return ( -
- {value} -
- ); -} diff --git a/web/src/components/storage/InstallationDeviceField.test.tsx b/web/src/components/storage/InstallationDeviceField.test.tsx deleted file mode 100644 index 9967d39550..0000000000 --- a/web/src/components/storage/InstallationDeviceField.test.tsx +++ /dev/null @@ -1,219 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @ts-check - -import React from "react"; -import { screen, within } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import InstallationDeviceField, { - InstallationDeviceFieldProps, -} from "~/components/storage/InstallationDeviceField"; -import { ProposalTarget, StorageDevice } from "~/types/storage"; - -jest.mock("@patternfly/react-core", () => { - const original = jest.requireActual("@patternfly/react-core"); - - return { - ...original, - Skeleton: () =>
PF-Skeleton
, - }; -}); - -const sda: StorageDevice = { - sid: 59, - isDrive: true, - type: ProposalTarget.DISK, - description: "", - 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, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], -}; - -const sdb: StorageDevice = { - sid: 62, - isDrive: true, - type: ProposalTarget.DISK, - description: "", - vendor: "Samsung", - model: "Samsung Evo 8 Pro", - driver: ["ahci"], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sdb", - size: 2048, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:00-19"], -}; - -let props: InstallationDeviceFieldProps; - -beforeEach(() => { - props = { - target: ProposalTarget.DISK, - targetDevice: sda, - targetPVDevices: [], - devices: [sda, sdb], - isLoading: false, - onChange: jest.fn(), - }; -}); - -describe.skip("when set as loading", () => { - beforeEach(() => { - props.isLoading = true; - }); - - it("renders a loading hint", () => { - installerRender(); - - // a PF skeleton is displayed - screen.getByText("PF-Skeleton"); - }); -}); - -describe.skip("when the target is a disk", () => { - beforeEach(() => { - props.target = ProposalTarget.DISK; - }); - - describe("and installation device is not selected yet", () => { - beforeEach(() => { - props.targetDevice = undefined; - }); - - it("uses a 'No device selected yet' text for the selection button", async () => { - installerRender(); - screen.getByText("No device selected yet"); - }); - }); - - describe("and an installation device is selected", () => { - beforeEach(() => { - props.targetDevice = sda; - }); - - it("uses its name as part of the text for the selection button", async () => { - installerRender(); - screen.getByText(/\/dev\/sda/); - }); - }); -}); - -describe.skip("when the target is a new LVM volume group", () => { - beforeEach(() => { - props.target = ProposalTarget.NEW_LVM_VG; - }); - - describe("and the target devices are not selected yet", () => { - beforeEach(() => { - props.targetPVDevices = []; - }); - - it("uses a 'No device selected yet' text for the selection button", async () => { - installerRender(); - screen.getByText("No device selected yet"); - }); - }); - - describe("and there is a selected device", () => { - beforeEach(() => { - props.targetPVDevices = [sda]; - }); - - it("uses its name as part of the text for the selection button", async () => { - installerRender(); - screen.getByText(/new LVM .* \/dev\/sda/); - }); - }); - - describe("and there are more than one selected device", () => { - beforeEach(() => { - props.targetPVDevices = [sda, sdb]; - }); - - it("does not use the names as part of the text for the selection button", async () => { - installerRender(); - screen.getByText("new LVM volume group"); - }); - }); -}); - -it.skip("allows changing the selected device", async () => { - const { user } = installerRender(); - const button = screen.getByRole("button", { name: /installation device/i }); - - await user.click(button); - - const selector = await screen.findByRole("dialog", { name: /Device for installing/ }); - const diskGrid = within(selector).getByRole("grid", { name: /target disk/ }); - const sdbRow = within(diskGrid).getByRole("row", { name: /sdb/ }); - const sdbOption = within(sdbRow).getByRole("radio"); - const accept = within(selector).getByRole("button", { name: "Confirm" }); - - await user.click(sdbOption); - await user.click(accept); - - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(props.onChange).toHaveBeenCalledWith({ - target: ProposalTarget.DISK, - targetDevice: sdb, - targetPVDevices: [], - }); -}); - -it.skip("allows canceling a device selection", async () => { - const { user } = installerRender(); - const button = screen.getByRole("button", { name: /installation device/i }); - - await user.click(button); - - const selector = await screen.findByRole("dialog", { name: /Device for installing/ }); - const diskGrid = within(selector).getByRole("grid", { name: /target disk/ }); - const sdbRow = within(diskGrid).getByRole("row", { name: /sdb/ }); - const sdbOption = within(sdbRow).getByRole("radio"); - const cancel = within(selector).getByRole("button", { name: "Cancel" }); - - await user.click(sdbOption); - await user.click(cancel); - - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(props.onChange).not.toHaveBeenCalled(); -}); diff --git a/web/src/components/storage/InstallationDeviceField.tsx b/web/src/components/storage/InstallationDeviceField.tsx deleted file mode 100644 index 624bc95b9b..0000000000 --- a/web/src/components/storage/InstallationDeviceField.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 { Skeleton } from "@patternfly/react-core"; -import { Link, Page } from "~/components/core"; -import { ProposalTarget, StorageDevice } from "~/types/storage"; -import { deviceLabel } from "~/components/storage/utils"; -import { STORAGE as PATHS } from "~/routes/paths"; -import { sprintf } from "sprintf-js"; -import { _ } from "~/i18n"; - -/** - * Generates the target value. - */ -const targetValue = ( - target: ProposalTarget, - targetDevice: StorageDevice, - targetPVDevices: StorageDevice[], -): string => { - if (target === ProposalTarget.DISK && targetDevice) { - // TRANSLATORS: %s is the installation disk (eg. "/dev/sda, 80 GiB) - return sprintf(_("File systems created as new partitions at %s"), deviceLabel(targetDevice)); - } - if (ProposalTarget.NEW_LVM_VG && targetPVDevices.length > 0) { - if (targetPVDevices.length > 1) return _("File systems created at a new LVM volume group"); - - if (targetPVDevices.length === 1) { - // TRANSLATORS: %s is the disk used for the LVM physical volumes (eg. "/dev/sda, 80 GiB) - return sprintf( - _("File systems created at a new LVM volume group on %s"), - deviceLabel(targetPVDevices[0]), - ); - } - } - - return _("No device selected yet"); -}; - -/** - * Allows to select the installation device. - * @component - */ - -export type TargetConfig = { - target: ProposalTarget; - targetDevice: StorageDevice | undefined; - targetPVDevices: StorageDevice[]; -}; - -export type InstallationDeviceFieldProps = { - // Installation target - target: ProposalTarget | undefined; - // Target device (for target "disk") - targetDevice: StorageDevice | undefined; - // Target devices for the LVM volume group (target "newLvmVg") - targetPVDevices: StorageDevice[]; - // Available devices for installation. - devices: StorageDevice[]; - isLoading: boolean; - onChange: (target: TargetConfig) => void; -}; - -export default function InstallationDeviceField({ - target, - targetDevice, - targetPVDevices, - isLoading, -}: InstallationDeviceFieldProps) { - let value: React.ReactNode; - if (isLoading || !target) value = ; - else value = targetValue(target, targetDevice, targetPVDevices); - - // TRANSLATORS: The storage "Installation device" field's description. - const description = _("Main disk or LVM Volume Group for installation."); - - return ( - - ) : ( - - {_("Change")} - - ) - } - > - {value} - - ); -} diff --git a/web/src/components/storage/InvalidMaxSizeError.tsx b/web/src/components/storage/InvalidMaxSizeError.tsx deleted file mode 100644 index 5bf1612d9e..0000000000 --- a/web/src/components/storage/InvalidMaxSizeError.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 { _ } from "~/i18n"; -import { SIZE_METHODS, SizeMethod } from "~/components/storage/utils"; - -export class InvalidMaxSizeError { - sizeMethod: SizeMethod; - minSize: string | number; - maxSize: string | number; - - constructor(sizeMethod: SizeMethod, minSize: string | number, maxSize: string | number) { - this.sizeMethod = sizeMethod; - this.minSize = minSize; - this.maxSize = maxSize; - } - - check(): boolean { - return ( - this.sizeMethod === SIZE_METHODS.RANGE && this.maxSize !== -1 && this.maxSize <= this.minSize - ); - } - - render(): string { - return _("Maximum must be greater than minimum"); - } -} diff --git a/web/src/components/storage/PartitionsField.test.tsx b/web/src/components/storage/PartitionsField.test.tsx deleted file mode 100644 index e4214fbd05..0000000000 --- a/web/src/components/storage/PartitionsField.test.tsx +++ /dev/null @@ -1,488 +0,0 @@ -/* - * Copyright (c) [2022-2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @ts-check - -import React from "react"; -import { screen, within } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import PartitionsField, { PartitionsFieldProps } from "~/components/storage/PartitionsField"; -import { ProposalTarget, StorageDevice, Volume, VolumeTarget } from "~/types/storage"; - -jest.mock("@patternfly/react-core", () => { - const original = jest.requireActual("@patternfly/react-core"); - - return { - ...original, - Skeleton: () =>
PFSkeleton
, - }; -}); - -const rootVolume: Volume = { - mountPath: "/", - target: VolumeTarget.DEFAULT, - fsType: "Btrfs", - minSize: 1024, - maxSize: 2048, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: true, - fsTypes: ["Btrfs", "Ext4"], - supportAutoSize: true, - snapshotsConfigurable: true, - snapshotsAffectSizes: true, - sizeRelevantVolumes: [], - adjustByRam: false, - productDefined: true, - }, -}; - -const swapVolume: Volume = { - mountPath: "swap", - target: VolumeTarget.DEFAULT, - fsType: "Swap", - minSize: 1024, - maxSize: 1024, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Swap"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - sizeRelevantVolumes: [], - adjustByRam: false, - productDefined: true, - }, -}; - -const homeVolume: Volume = { - mountPath: "/home", - target: VolumeTarget.DEFAULT, - fsType: "XFS", - minSize: 1024, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - sizeRelevantVolumes: [], - adjustByRam: false, - productDefined: true, - }, -}; - -const arbitraryVolume: Volume = { - mountPath: "", - target: VolumeTarget.DEFAULT, - fsType: "XFS", - minSize: 1024, - maxSize: 4096, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - adjustByRam: false, - sizeRelevantVolumes: [], - productDefined: false, - }, -}; - -const sda: StorageDevice = { - sid: 59, - name: "/dev/sda", - description: "", - isDrive: true, - type: "disk", - vendor: "Micron", - model: "Micron 1100 SATA", - transport: "usb", - size: 1024, -}; - -const sda1: StorageDevice = { - sid: 69, - name: "/dev/sda1", - description: "", - isDrive: false, - type: "partition", - size: 256, - filesystem: { - sid: 169, - type: "Swap", - }, -}; - -const sda2: StorageDevice = { - sid: 79, - name: "/dev/sda2", - description: "", - isDrive: false, - type: "partition", - size: 512, - filesystem: { - sid: 179, - type: "Ext4", - }, -}; - -let props: PartitionsFieldProps; - -const expandField = async () => { - const render = plainRender(); - const button = screen.getByRole("button", { name: "Partitions and file systems" }); - await render.user.click(button); - return render; -}; - -beforeEach(() => { - props = { - volumes: [rootVolume, swapVolume], - templates: [], - availableDevices: [], - volumeDevices: [sda], - target: ProposalTarget.DISK, - targetDevices: [], - configureBoot: false, - bootDevice: undefined, - defaultBootDevice: undefined, - onVolumesChange: jest.fn(), - }; -}); - -it.skip("allows to reset the file systems", async () => { - const { user } = await expandField(); - const button = screen.getByRole("button", { name: "Reset to defaults" }); - await user.click(button); - - expect(props.onVolumesChange).toHaveBeenCalledWith([]); -}); - -it.skip("renders a button for adding a file system when only arbitrary volumes can be added", async () => { - props.templates = [arbitraryVolume]; - const { user } = await expandField(); - const button = screen.getByRole("button", { name: "Add file system" }); - expect(button).not.toHaveAttribute("aria-expanded"); - await user.click(button); - screen.getByRole("dialog", { name: "Add file system" }); -}); - -it.skip("renders a menu for adding a file system when predefined and arbitrary volume can be added", async () => { - props.templates = [homeVolume, arbitraryVolume]; - const { user } = await expandField(); - - const button = screen.getByRole("button", { name: "Add file system" }); - expect(button).toHaveAttribute("aria-expanded", "false"); - await user.click(button); - - expect(button).toHaveAttribute("aria-expanded", "true"); - const homeOption = screen.getByRole("menuitem", { name: "/home" }); - await user.click(homeOption); - - screen.getByRole("dialog", { name: "Add /home file system" }); -}); - -it.skip("renders the control for adding a file system when using transactional system with optional templates", async () => { - props.templates = [{ ...rootVolume, transactional: true }, homeVolume]; - await expandField(); - screen.queryByRole("button", { name: "Add file system" }); -}); - -it.skip("does not render the control for adding a file system when using transactional system with no optional templates", async () => { - props.templates = [{ ...rootVolume, transactional: true }]; - await expandField(); - expect(screen.queryByRole("button", { name: "Add file system" })).toBeNull(); -}); - -it.skip("renders the control as disabled when there are no more left predefined volumes to add and arbitrary volumes are not allowed", async () => { - props.templates = [rootVolume, homeVolume]; - props.volumes = [rootVolume, homeVolume]; - await expandField(); - const button = screen.getByRole("button", { name: "Add file system" }); - expect(button).toBeDisabled(); -}); - -it.skip("allows to add a file system", async () => { - props.templates = [homeVolume]; - const { user } = await expandField(); - - const button = screen.getByRole("button", { name: "Add file system" }); - await user.click(button); - const homeOption = screen.getByRole("menuitem", { name: "/home" }); - await user.click(homeOption); - - const dialog = await screen.findByRole("dialog"); - const accept = within(dialog).getByRole("button", { name: "Accept" }); - await user.click(accept); - - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(props.onVolumesChange).toHaveBeenCalledWith([rootVolume, swapVolume, homeVolume]); -}); - -it.skip("allows to cancel adding a file system", async () => { - props.templates = [arbitraryVolume]; - const { user } = await expandField(); - - const button = screen.getByRole("button", { name: "Add file system" }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - const cancel = within(popup).getByRole("button", { name: "Cancel" }); - await user.click(cancel); - - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(props.onVolumesChange).not.toHaveBeenCalled(); -}); - -describe.skip("if there are volumes", () => { - beforeEach(() => { - props.volumes = [rootVolume, homeVolume, swapVolume]; - }); - - it("renders skeleton for each volume if loading", async () => { - props.isLoading = true; - await expandField(); - - const [, body] = await screen.findAllByRole("rowgroup"); - - const rows = within(body).getAllByRole("row"); - expect(rows.length).toEqual(3); - - const loadingRows = within(body).getAllByRole("row", { name: "PFSkeleton" }); - expect(loadingRows.length).toEqual(3); - }); - - it("renders the information for each volume", async () => { - await expandField(); - - const [, body] = await screen.findAllByRole("rowgroup"); - - expect(within(body).queryAllByRole("row").length).toEqual(3); - within(body).getByRole("row", { name: "/ Btrfs 1 KiB - 2 KiB Partition at installation disk" }); - within(body).getByRole("row", { - name: "/home XFS at least 1 KiB Partition at installation disk", - }); - within(body).getByRole("row", { name: "swap Swap 1 KiB Partition at installation disk" }); - }); - - it("allows deleting the volume", async () => { - const { user } = await expandField(); - - const [, body] = await screen.findAllByRole("rowgroup"); - const row = within(body).getByRole("row", { - name: "/home XFS at least 1 KiB Partition at installation disk", - }); - const actions = within(row).getByRole("button", { name: "Actions" }); - await user.click(actions); - const deleteAction = within(row).queryByRole("menuitem", { name: "Delete" }); - await user.click(deleteAction); - - expect(props.onVolumesChange).toHaveBeenCalledWith(expect.not.arrayContaining([homeVolume])); - }); - - it("allows editing the volume", async () => { - const { user } = await expandField(); - - const [, body] = await screen.findAllByRole("rowgroup"); - const row = within(body).getByRole("row", { - name: "/home XFS at least 1 KiB Partition at installation disk", - }); - 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).getByRole("heading", { name: "Edit /home file system" }); - }); - - it("allows changing the location of the volume", async () => { - const { user } = await expandField(); - - const [, body] = await screen.findAllByRole("rowgroup"); - const row = within(body).getByRole("row", { - name: "/home XFS at least 1 KiB Partition at installation disk", - }); - const actions = within(row).getByRole("button", { name: "Actions" }); - await user.click(actions); - const locationAction = within(row).queryByRole("menuitem", { name: "Change location" }); - await user.click(locationAction); - - const popup = await screen.findByRole("dialog"); - within(popup).getByText("Location for /home file system"); - }); - - // FIXME: improve at least the test description - it("does not allow resetting the volume location when already using the default location", async () => { - const { user } = await expandField(); - - const [, body] = await screen.findAllByRole("rowgroup"); - const row = within(body).getByRole("row", { - name: "/home XFS at least 1 KiB Partition at installation disk", - }); - const actions = within(row).getByRole("button", { name: "Actions" }); - await user.click(actions); - expect(within(row).queryByRole("menuitem", { name: "Reset location" })).toBeNull(); - }); - - describe("and a volume has a non default location", () => { - beforeEach(() => { - props.volumes = [{ ...homeVolume, target: VolumeTarget.NEW_PARTITION, targetDevice: sda }]; - }); - - it("allows resetting the volume location", async () => { - const { user } = await expandField(); - - const [, body] = await screen.findAllByRole("rowgroup"); - const row = within(body).getByRole("row", { - name: "/home XFS at least 1 KiB Partition at /dev/sda", - }); - const actions = within(row).getByRole("button", { name: "Actions" }); - await user.click(actions); - const resetLocationAction = within(row).queryByRole("menuitem", { name: "Reset location" }); - await user.click(resetLocationAction); - expect(props.onVolumesChange).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - mountPath: "/home", - target: "DEFAULT", - targetDevice: undefined, - }), - ]), - ); - - // NOTE: sadly we cannot perform the below check because the component is - // always receiving the same mocked props and will still having a /home as - // "Partition at /dev/sda" - // await within(body).findByRole("row", { name: "/home XFS at least 1 KiB Partition at installation device" }); - }); - }); - - describe("and there is a transactional Btrfs root volume", () => { - beforeEach(() => { - props.volumes = [{ ...rootVolume, snapshots: true, transactional: true }]; - }); - - it("renders 'transactional' legend as part of its information", async () => { - await expandField(); - - const [, volumes] = await screen.findAllByRole("rowgroup"); - - within(volumes).getByRole("row", { - name: "/ Transactional Btrfs 1 KiB - 2 KiB Partition at installation disk", - }); - }); - }); - - describe("and there is Btrfs volume using snapshots", () => { - beforeEach(() => { - props.volumes = [{ ...rootVolume, snapshots: true, transactional: false }]; - }); - - it("renders 'with snapshots' legend as part of its information", async () => { - await expandField(); - - const [, volumes] = await screen.findAllByRole("rowgroup"); - - within(volumes).getByRole("row", { - name: "/ Btrfs with snapshots 1 KiB - 2 KiB Partition at installation disk", - }); - }); - }); - - describe("and some volumes are allocated at separate disks", () => { - beforeEach(() => { - props.volumes = [ - rootVolume, - { ...swapVolume, target: VolumeTarget.NEW_PARTITION, targetDevice: sda }, - { ...homeVolume, target: VolumeTarget.NEW_VG, targetDevice: sda }, - ]; - }); - - it("renders the locations", async () => { - await expandField(); - - const [, volumes] = await screen.findAllByRole("rowgroup"); - - within(volumes).getByRole("row", { name: "swap Swap 1 KiB Partition at /dev/sda" }); - within(volumes).getByRole("row", { - name: "/home XFS at least 1 KiB Separate LVM at /dev/sda", - }); - }); - }); - - describe("and some volumes are reusing existing block devices", () => { - beforeEach(() => { - props.volumes = [ - rootVolume, - { ...swapVolume, target: VolumeTarget.FILESYSTEM, targetDevice: sda1 }, - { ...homeVolume, target: VolumeTarget.DEVICE, targetDevice: sda2 }, - ]; - }); - - it("renders the locations", async () => { - await expandField(); - - const [, volumes] = await screen.findAllByRole("rowgroup"); - - within(volumes).getByRole("row", { name: "swap Reused Swap 256 B /dev/sda1" }); - within(volumes).getByRole("row", { name: "/home XFS 512 B /dev/sda2" }); - }); - }); -}); - -describe.skip("if there are not volumes", () => { - beforeEach(() => { - props.volumes = []; - }); - - it("renders an empty table if it is not loading", async () => { - await expandField(); - - const [, body] = await screen.findAllByRole("rowgroup"); - expect(body).toBeEmptyDOMElement(); - }); - - it("renders an skeleton row if it is loading", async () => { - props.isLoading = true; - - await expandField(); - - const [, body] = await screen.findAllByRole("rowgroup"); - const rows = within(body).getAllByRole("row", { name: "PFSkeleton" }); - - expect(rows.length).toEqual(1); - }); -}); diff --git a/web/src/components/storage/PartitionsField.tsx b/web/src/components/storage/PartitionsField.tsx deleted file mode 100644 index 3869843e1c..0000000000 --- a/web/src/components/storage/PartitionsField.tsx +++ /dev/null @@ -1,848 +0,0 @@ -/* - * Copyright (c) [2022-2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React, { useState } from "react"; -import { - Button, - CardExpandableContent, - Divider, - Dropdown, - DropdownItem, - DropdownList, - Flex, - List, - ListItem, - MenuToggle, - Skeleton, - Split, - Stack, -} from "@patternfly/react-core"; -import { Table, Thead, Tr, Th, Tbody, Td } from "@patternfly/react-table"; -import { Page, RowActions, Tip } from "~/components/core"; -import { noop } from "~/utils"; -import { _ } from "~/i18n"; -import { sprintf } from "sprintf-js"; -import { - deviceSize, - hasSnapshots, - isTransactionalRoot, - isTransactionalSystem, - reuseDevice, -} from "~/components/storage/utils"; -import BootConfigField from "~/components/storage/BootConfigField"; -import SnapshotsField, { SnapshotsConfig } from "~/components/storage/SnapshotsField"; -import VolumeDialog from "./VolumeDialog"; -import VolumeLocationDialog from "~/components/storage/VolumeLocationDialog"; -import { ProposalTarget, StorageDevice, Volume, VolumeTarget } from "~/types/storage"; - -/** - * @component - */ -const SizeText = ({ volume }: { volume: Volume }) => { - let targetSize: number; - if (reuseDevice(volume)) targetSize = volume.targetDevice.size; - - const minSize = deviceSize(targetSize || volume.minSize); - const maxSize = targetSize - ? deviceSize(targetSize) - : volume.maxSize - ? deviceSize(volume.maxSize) - : undefined; - - if (minSize && maxSize && minSize !== maxSize) return `${minSize} - ${maxSize}`; - // TRANSLATORS: minimum device size, %s is replaced by size string, e.g. "17.5 GiB" - if (maxSize === undefined) return sprintf(_("at least %s"), minSize); - - return `${minSize}`; -}; - -const BasicVolumeText = ({ volume, target }: { volume: Volume; target: ProposalTarget }) => { - const snapshots = hasSnapshots(volume); - const transactional = isTransactionalRoot(volume); - const size = SizeText({ volume }); - const lvm = target === ProposalTarget.NEW_LVM_VG; - // When target is "filesystem" or "device" this is irrelevant since the type of device - // is not mentioned - const lv = - volume.target === VolumeTarget.NEW_VG || (volume.target === VolumeTarget.DEFAULT && lvm); - - if (transactional) - return lv - ? // TRANSLATORS: "/" is in an LVM logical volume. %s replaced by size string, e.g. "17.5 GiB" - sprintf(_("Transactional Btrfs root volume (%s)"), size) - : // TRANSLATORS: %s replaced by size string, e.g. "17.5 GiB" - sprintf(_("Transactional Btrfs root partition (%s)"), size); - - if (snapshots) - return lv - ? // TRANSLATORS: "/" is in an LVM logical volume. %s replaced by size string, e.g. "17.5 GiB" - sprintf(_("Btrfs root volume with snapshots (%s)"), size) - : // TRANSLATORS: %s replaced by size string, e.g. "17.5 GiB" - sprintf(_("Btrfs root partition with snapshots (%s)"), size); - - const volTarget = volume.target; - const mount = volume.mountPath; - const device = volume.targetDevice?.name; - - if (volTarget === VolumeTarget.FILESYSTEM) - // TRANSLATORS: This results in something like "Mount /dev/sda3 at /home (25 GiB)" since - // %1$s is replaced by the device name, %2$s by the mount point and %3$s by the size - return sprintf(_("Mount %1$s at %2$s (%3$s)"), device, mount, size); - - if (mount === "swap") { - if (volTarget === VolumeTarget.DEVICE) - // TRANSLATORS: This results in something like "Swap at /dev/sda3 (2 GiB)" since - // %1$s is replaced by the device name, and %2$s by the size - return sprintf(_("Swap at %1$s (%2$s)"), device, size); - - return lv - ? // TRANSLATORS: Swap is in an LVM logical volume. %s replaced by size string, e.g. "8 GiB" - sprintf(_("Swap volume (%s)"), size) - : // TRANSLATORS: %s replaced by size string, e.g. "8 GiB" - sprintf(_("Swap partition (%s)"), size); - } - - const type = volume.fsType; - - if (mount === "/") { - if (volTarget === VolumeTarget.DEVICE) - // TRANSLATORS: This results in something like "Btrfs root at /dev/sda3 (20 GiB)" since - // %1$s is replaced by the filesystem type, %2$s by the device name, and %3$s by the size - return sprintf(_("%1$s root at %2$s (%3$s)"), type, device, size); - - return lv - ? // TRANSLATORS: "/" is in an LVM logical volume. - // Results in something like "Btrfs root volume (at least 20 GiB)" since - // $1$s is replaced by filesystem type and %2$s by size description - sprintf(_("%1$s root volume (%2$s)"), type, size) - : // TRANSLATORS: Results in something like "Btrfs root partition (at least 20 GiB)" since - // $1$s is replaced by filesystem type and %2$s by size description - sprintf(_("%1$s root partition (%2$s)"), type, size); - } - - if (volTarget === VolumeTarget.DEVICE) - // TRANSLATORS: This results in something like "Ext4 /home at /dev/sda3 (20 GiB)" since - // %1$s is replaced by filesystem type, %2$s by mount point, %3$s by device name and %4$s by size - return sprintf(_("%1$s %2$s at %3$s (%4$s)"), type, mount, device, size); - - return lv - ? // TRANSLATORS: The filesystem is in an LVM logical volume. - // Results in something like "Ext4 /home volume (at least 10 GiB)" since - // %1$s is replaced by the filesystem type, %2$s by the mount point and %3$s by the size description - sprintf(_("%1$s %2$s volume (%3$s)"), type, mount, size) - : // TRANSLATORS: This results in something like "Ext4 /home partition (at least 10 GiB)" since - // %1$s is replaced by the filesystem type, %2$s by the mount point and %3$s by the size description - sprintf(_("%1$s %2$s partition (%3$s)"), type, mount, size); -}; - -/** - * Generates a text explaining the system boot configuration. - * @component - */ -const BootLabelText = ({ configure, device }: { configure: boolean; device: StorageDevice }) => { - if (!configure) return _("Do not configure partitions for booting"); - - if (!device) return _("Boot partitions at installation disk"); - - // TRANSLATORS: %s is the disk used to configure the boot-related partitions (eg. "/dev/sda, 80 GiB) - return sprintf(_("Boot partitions at %s"), device.name); -}; - -/** - * Generates an hint describing which attributes affect the auto-calculated limits. - * If the limits are not affected then it returns `null`. - * @component - */ -const AutoCalculatedHint = ({ volume }: { volume: Volume }) => { - const { snapshotsAffectSizes = false, sizeRelevantVolumes = [], adjustByRam } = volume.outline; - - // no hint, the size is not affected by known criteria - if (!snapshotsAffectSizes && !adjustByRam && sizeRelevantVolumes.length === 0) { - return null; - } - - return ( - <> - {/* TRANSLATORS: header for a list of items referring to size limits for file systems */} - {_("These limits are affected by:")} - - {snapshotsAffectSizes && ( - // TRANSLATORS: list item, this affects the computed partition size limits - {_("The configuration of snapshots")} - )} - {sizeRelevantVolumes.length > 0 && ( - // TRANSLATORS: list item, this affects the computed partition size limits - // %s is replaced by a list of the volumes (like "/home, /boot") - - {sprintf(_("Presence of other volumes (%s)"), sizeRelevantVolumes.join(", "))} - - )} - {adjustByRam && ( - // TRANSLATORS: list item, describes a factor that affects the computed size of a - // file system; eg. adjusting the size of the swap - {_("The amount of RAM in the system")} - )} - - - ); -}; - -/** - * @component - */ -const VolumeLabel = ({ volume, target }: { volume: Volume; target: ProposalTarget }) => { - return ( - - {BasicVolumeText({ volume, target })} - - ); -}; - -const BootLabel = ({ - bootDevice, - configureBoot, -}: { - bootDevice: StorageDevice | undefined; - configureBoot: boolean; -}) => { - return ( - - {BootLabelText({ configure: configureBoot, device: bootDevice })} - - ); -}; - -// TODO: Extract VolumesTable or at least VolumeRow and all related internal -// components to a new file. - -const VolumeSizeLimits = ({ volume }: { volume: Volume }) => { - const isAuto = volume.autoSize; - - return ( - - {SizeText({ volume })} - {/* TRANSLATORS: device flag, the partition size is automatically computed */} - {isAuto && !reuseDevice(volume) && ( - {_("auto")} - )} - - ); -}; - -const VolumeDetails = ({ volume }: { volume: Volume }) => { - const snapshots = hasSnapshots(volume); - const transactional = isTransactionalRoot(volume); - - if (volume.target === VolumeTarget.FILESYSTEM) - // TRANSLATORS: %s will be replaced by a file-system type like "Btrfs" or "Ext4" - return sprintf(_("Reused %s"), volume.targetDevice?.filesystem?.type || ""); - if (transactional) return _("Transactional Btrfs"); - if (snapshots) return _("Btrfs with snapshots"); - - return volume.fsType; -}; - -type VolumeLocationProps = { - volume: Volume; - target: ProposalTarget; -}; - -const VolumeLocation = ({ volume, target }: VolumeLocationProps) => { - if (volume.target === VolumeTarget.NEW_PARTITION) - // TRANSLATORS: %s will be replaced by a disk name (eg. "/dev/sda") - return sprintf(_("Partition at %s"), volume.targetDevice?.name || ""); - if (volume.target === VolumeTarget.NEW_VG) - // TRANSLATORS: %s will be replaced by a disk name (eg. "/dev/sda") - return sprintf(_("Separate LVM at %s"), volume.targetDevice?.name || ""); - if (volume.target === VolumeTarget.DEVICE || volume.target === VolumeTarget.FILESYSTEM) - return volume.targetDevice?.name || ""; - if (target === ProposalTarget.NEW_LVM_VG) return _("Logical volume at system LVM"); - - return _("Partition at installation disk"); -}; - -type VolumeActionsProps = { - volume: Volume; - onEdit: () => void; - onResetLocation: () => void; - onLocation: () => void; - onDelete: () => void; -}; - -const VolumeActions = ({ - volume, - onEdit, - onResetLocation, - onLocation, - onDelete, -}: VolumeActionsProps) => { - const actions = [ - { title: _("Edit"), onClick: onEdit }, - volume.target !== "default" && { title: _("Reset location"), onClick: onResetLocation }, - { title: _("Change location"), onClick: onLocation }, - !volume.outline.required && { title: _("Delete"), onClick: onDelete, isDanger: true }, - ]; - - return ; -}; - -type VolumeRowProps = { - columns?; - volume?: Volume; - volumes?: Volume[]; - templates?: Volume[]; - volumeDevices?: StorageDevice[]; - target?: ProposalTarget; - targetDevices?: StorageDevice[]; - isLoading: boolean; - onEdit?: (volume: Volume) => void; - onDelete?: () => void; -}; - -/** - * Renders a table row with the information and actions for a volume - * @component - */ -const VolumeRow = ({ - columns, - volume, - volumes, - templates, - volumeDevices, - target, - targetDevices, - isLoading, - onEdit = noop, - onDelete = noop, -}: VolumeRowProps) => { - const [dialog, setDialog] = useState(); - - const openEditDialog = () => setDialog("edit"); - - const openLocationDialog = () => setDialog("location"); - - const closeDialog = () => setDialog(undefined); - - const onResetLocationClick = () => { - onEdit({ ...volume, target: VolumeTarget.DEFAULT, targetDevice: undefined }); - }; - - const acceptForm = (volume: Volume) => { - closeDialog(); - onEdit(volume); - }; - - const isEditDialogOpen = dialog === "edit"; - const isLocationDialogOpen = dialog === "location"; - - if (isLoading) { - return ( - - - - - - ); - } - - return ( - <> - - {volume.mountPath} - - - - - - - - - - - - - - {isEditDialogOpen && ( - - )} - {isLocationDialogOpen && ( - - )} - - ); -}; - -type VolumesTableProps = { - volumes: Volume[]; - templates: Volume[]; - volumeDevices: StorageDevice[]; - target: ProposalTarget; - targetDevices: StorageDevice[]; - isLoading: boolean; - onVolumesChange: (volumes: Volume[]) => void; -}; - -/** - * Renders a table with the information and actions of the volumes - * @component - */ -const VolumesTable = ({ - volumes, - templates, - volumeDevices, - target, - targetDevices, - isLoading, - onVolumesChange, -}: VolumesTableProps) => { - const columns = { - mountPath: _("Mount point"), - details: _("Details"), - size: _("Size"), - // TRANSLATORS: where (and how) the file-system is going to be created - location: _("Location"), - actions: _("Actions"), - }; - - const editVolume = (volume: Volume) => { - const index = volumes.findIndex((v) => v.mountPath === volume.mountPath); - const newVolumes = [...volumes]; - newVolumes[index] = volume; - onVolumesChange(newVolumes); - }; - - const deleteVolume = (volume: Volume) => { - const newVolumes = volumes.filter((v) => v.mountPath !== volume.mountPath); - onVolumesChange(newVolumes); - }; - - const renderVolumes: () => React.ReactElement[] | React.ReactElement = () => { - if (volumes.length === 0 && isLoading) return ; - - return volumes.map((volume, index) => { - return ( - deleteVolume(volume)} - /> - ); - }); - }; - - return ( - - - - - - - - - - {renderVolumes()} -
{columns.mountPath}{columns.details}{columns.size}{columns.location} -
- ); -}; - -/** - * Content to show when the field is collapsed. - * @component - */ -const Basic = ({ - volumes, - configureBoot, - bootDevice, - target, - isLoading, -}: { - volumes: Volume[]; - configureBoot: boolean; - bootDevice: StorageDevice | undefined; - target: ProposalTarget; - isLoading: boolean; -}) => { - if (isLoading) - return ( - - - - - - ); - - return ( - - {volumes.map((v, i) => ( - - ))} - - - ); -}; - -/** - * Button for adding a new volume. It renders either a menu or a button depending on the number - * of options. - * @component - * - * @param props - * @param props.options - Possible mount points to add. An empty string represent an - * arbitrary mount point. - * @param props.onClick - */ -const AddVolumeButton = ({ - options, - onClick, -}: { - options: string[]; - onClick: (option: string) => void; -}) => { - const [isOpen, setIsOpen] = React.useState(false); - - const onToggleClick: () => void = () => setIsOpen(!isOpen); - - const onSelect: (_, value: string) => void = (_, value): void => { - setIsOpen(false); - onClick(value); - }; - - // Shows a button if the only option is to add an arbitrary volume. - if (options.length === 1 && options[0] === "") { - return ( - - ); - } - - const isDisabled = !options.length; - - return ( - ( - - {_("Add file system")} - - )} - shouldFocusToggleOnSelect - > - - {options.map((option, index) => { - if (option === "") { - return ( - - - - {_("Other")} - - - ); - } else { - return ( - - {option} - - ); - } - })} - - - ); -}; - -type AdvancedProps = { - volumes: Volume[]; - templates: Volume[]; - availableDevices: StorageDevice[]; - volumeDevices: StorageDevice[]; - target: ProposalTarget; - targetDevices: StorageDevice[]; - configureBoot: boolean; - bootDevice: StorageDevice | undefined; - defaultBootDevice: StorageDevice | undefined; - onVolumesChange: (volumes: Volume[]) => void; - isLoading: boolean; -}; - -/** - * Content to show when the field is expanded. - * @component - * - */ -const Advanced = ({ - volumes, - templates, - availableDevices, - volumeDevices, - target, - targetDevices, - configureBoot, - bootDevice, - defaultBootDevice, - onVolumesChange, - isLoading, -}: AdvancedProps) => { - const [isVolumeDialogOpen, setIsVolumeDialogOpen] = useState(false); - const [template, setTemplate] = useState(); - - const openVolumeDialog = () => setIsVolumeDialogOpen(true); - - const closeVolumeDialog = () => setIsVolumeDialogOpen(false); - - const onAcceptVolumeDialog: (volume: Volume) => void = (volume) => { - closeVolumeDialog(); - - const index = volumes.findIndex((v) => v.mountPath === volume.mountPath); - - if (index !== -1) { - const newVolumes = [...volumes]; - newVolumes[index] = volume; - onVolumesChange(newVolumes); - } else { - onVolumesChange([...volumes, volume]); - } - }; - - const resetVolumes = () => onVolumesChange([]); - - const addVolume: (mountPath: string) => void = (mountPath) => { - const template = templates.find((t) => t.mountPath === mountPath); - setTemplate(template); - openVolumeDialog(); - }; - - /** - * Possible mount paths to add. - */ - const mountPathOptions: () => string[] = () => { - const mountPaths = volumes.map((v) => v.mountPath); - const isTransactional = isTransactionalSystem(templates); - - return templates - .map((t) => t.mountPath) - .filter((p) => !mountPaths.includes(p)) - .filter((p) => !isTransactional || p.length); - }; - - /** - * Whether to show the button for adding a volume. - */ - const showAddVolume: () => boolean = () => { - const hasOptionalVolumes = () => { - return templates.find((t) => t.mountPath.length && !t.outline.required) !== undefined; - }; - - return !isTransactionalSystem(templates) || hasOptionalVolumes(); - }; - - const rootVolume = volumes.find((v: Volume) => v.mountPath === "/"); - - const changeBtrfsSnapshots: (config: SnapshotsConfig) => void = ({ active }) => { - if (active) { - rootVolume.fsType = "Btrfs"; - rootVolume.snapshots = true; - } else { - rootVolume.snapshots = false; - } - - onVolumesChange(volumes); - }; - - const showSnapshotsField = rootVolume?.outline.snapshotsConfigurable; - - return ( - - {showSnapshotsField && ( - - )} - - - {showAddVolume() && } - - - {isVolumeDialogOpen && ( - - )} - - - - ); -}; - -export type PartitionsFieldProps = { - volumes: Volume[]; - templates: Volume[]; - availableDevices: StorageDevice[]; - volumeDevices: StorageDevice[]; - target: ProposalTarget; - targetDevices: StorageDevice[]; - configureBoot: boolean; - bootDevice: StorageDevice | undefined; - defaultBootDevice: StorageDevice | undefined; - isLoading?: boolean; - onVolumesChange: (volumes: Volume[]) => void; -}; - -/** - * @todo This component should be restructured to use the same approach as other newer components: - * * Use a TreeTable, specially if we need to represent subvolumes. - * - * Renders information of the volumes and boot-related partitions and actions to modify them. - * @component - */ -export default function PartitionsField({ - volumes, - templates, - availableDevices, - volumeDevices, - target, - targetDevices, - configureBoot, - bootDevice, - defaultBootDevice, - isLoading = false, - onVolumesChange, -}: PartitionsFieldProps) { - const [isExpanded, setIsExpanded] = useState(false); - const onExpand = () => setIsExpanded(!isExpanded); - - return ( - - {!isExpanded && ( - - )} - - - - - ); -} diff --git a/web/src/components/storage/ProposalPage.test.tsx b/web/src/components/storage/ProposalPage.test.tsx index f16f32d8fc..bd1cc78388 100644 --- a/web/src/components/storage/ProposalPage.test.tsx +++ b/web/src/components/storage/ProposalPage.test.tsx @@ -43,7 +43,6 @@ jest.mock("~/queries/issues", () => ({ useIssues: () => [], })); -jest.mock("./ProposalSettingsSection", () => () =>
proposal settings
); jest.mock("./ProposalResultSection", () => () =>
result section
); jest.mock("./ProposalTransactionalInfo", () => () =>
trasactional info
); diff --git a/web/src/components/storage/ProposalSettingsSection.test.tsx b/web/src/components/storage/ProposalSettingsSection.test.tsx deleted file mode 100644 index 85dae759d7..0000000000 --- a/web/src/components/storage/ProposalSettingsSection.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright (c) [2022-2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 { ProposalSettingsSection } from "~/components/storage"; -import { ProposalTarget } from "~/types/storage"; -import { ProposalSettingsSectionProps } from "./ProposalSettingsSection"; - -let props: ProposalSettingsSectionProps; - -beforeEach(() => { - props = { - settings: { - target: ProposalTarget.DISK, - targetDevice: "/dev/sda", - targetPVDevices: [], - configureBoot: false, - bootDevice: "", - defaultBootDevice: "", - encryptionPassword: "", - encryptionMethod: "", - spacePolicy: "delete", - spaceActions: [], - volumes: [], - installationDevices: [], - }, - availableDevices: [], - volumeDevices: [], - encryptionMethods: [], - volumeTemplates: [], - onChange: jest.fn(), - }; -}); - -it("allows changing the selected device", () => { - plainRender(); - const region = screen.getByRole("region", { name: "Installation device" }); - const link: HTMLAnchorElement = within(region).getByRole("link", { name: "Change" }); - expect(link.href).toMatch(/storage\/target-device/); -}); - -it("allows changing the encryption settings", async () => { - const { user } = plainRender(); - const region = screen.getByRole("region", { name: "Encryption" }); - const button = within(region).getByRole("button", { name: "Enable" }); - await user.click(button); - await screen.findByRole("dialog", { name: "Encryption" }); -}); - -it("renders a section holding file systems related stuff", () => { - plainRender(); - const region = screen.getByRole("region", { name: "Partitions and file systems" }); - expect(region).toBeInTheDocument(); -}); diff --git a/web/src/components/storage/ProposalSettingsSection.tsx b/web/src/components/storage/ProposalSettingsSection.tsx deleted file mode 100644 index 03878de865..0000000000 --- a/web/src/components/storage/ProposalSettingsSection.tsx +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright (c) [2022-2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 { Grid, GridItem } from "@patternfly/react-core"; -import EncryptionField, { EncryptionConfig } from "~/components/storage/EncryptionField"; -import InstallationDeviceField, { - TargetConfig, -} from "~/components/storage/InstallationDeviceField"; -import PartitionsField from "~/components/storage/PartitionsField"; -import { CHANGING, NOT_AFFECTED } from "~/components/storage/ProposalPage"; -import { ProposalSettings, StorageDevice, Volume } from "~/types/storage"; -import { compact } from "~/utils"; - -/** - * A helper function to decide whether to show the progress skeletons or not - * for the specified component - * @param loading - loading status - * @param component - name of the component - * @param changing - the item which is being changed - * @returns {boolean} true if the skeleton should be displayed, false otherwise - */ -const showSkeleton = (loading: boolean, component: string, changing: symbol): boolean => { - return loading && !NOT_AFFECTED[component].includes(changing); -}; - -export type ProposalSettingsSectionProps = { - settings: ProposalSettings; - availableDevices: StorageDevice[]; - volumeDevices: StorageDevice[]; - encryptionMethods: string[]; - volumeTemplates: Volume[]; - isLoading?: boolean; - changing?: symbol; - onChange: (changing: symbol, settings: object) => void; -}; - -/** - * Section for editing the proposal settings - * @component - */ -export default function ProposalSettingsSection({ - settings, - availableDevices, - volumeDevices, - encryptionMethods, - volumeTemplates, - isLoading = false, - changing = undefined, - onChange, -}: ProposalSettingsSectionProps) { - const changeTarget = ({ target, targetDevice, targetPVDevices }: TargetConfig) => { - onChange(CHANGING.TARGET, { - target, - targetDevice: targetDevice?.name, - targetPVDevices: targetPVDevices.map((d) => d.name), - }); - }; - - const changeEncryption = ({ password, method }: EncryptionConfig) => { - onChange(CHANGING.ENCRYPTION, { encryptionPassword: password, encryptionMethod: method }); - }; - - const changeVolumes = (volumes: Volume[]) => { - onChange(CHANGING.VOLUMES, { volumes }); - }; - - /** - * @param {string} name - * @returns {StorageDevice|undefined} - */ - const findDevice = (name: string): StorageDevice | undefined => - availableDevices.find((a) => a.name === name); - - const targetDevice: StorageDevice | undefined = findDevice(settings.targetDevice); - const targetPVDevices: StorageDevice[] = compact(settings.targetPVDevices?.map(findDevice) || []); - const { volumes = [] } = settings; - const bootDevice = findDevice(settings.bootDevice); - const defaultBootDevice = findDevice(settings.defaultBootDevice); - const targetDevices = compact([targetDevice, ...targetPVDevices]); - - return ( - - - - - - - - - - - - ); -} diff --git a/web/src/components/storage/SnapshotsField.test.tsx b/web/src/components/storage/SnapshotsField.test.tsx deleted file mode 100644 index 9603e4a61a..0000000000 --- a/web/src/components/storage/SnapshotsField.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @ts-check - -import React from "react"; -import { screen } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import SnapshotsField, { SnapshotsFieldProps } from "~/components/storage/SnapshotsField"; -import { Volume, VolumeTarget } from "~/types/storage"; - -const rootVolume: Volume = { - mountPath: "/", - target: VolumeTarget.DEFAULT, - fsType: "Btrfs", - minSize: 1024, - autoSize: true, - snapshots: true, - transactional: false, - outline: { - required: true, - fsTypes: ["ext4", "btrfs"], - supportAutoSize: true, - snapshotsConfigurable: false, - snapshotsAffectSizes: true, - adjustByRam: false, - sizeRelevantVolumes: ["/home"], - productDefined: true, - }, -}; - -const onChangeFn = jest.fn(); - -let props: SnapshotsFieldProps; - -describe("SnapshotsField", () => { - it("reflects snapshots status", () => { - props = { rootVolume: { ...rootVolume, snapshots: true }, onChange: onChangeFn }; - plainRender(); - const checkbox: HTMLInputElement = screen.getByRole("checkbox"); - expect(checkbox.value).toEqual("on"); - }); - - it("allows toggling snapshots status", async () => { - props = { rootVolume: { ...rootVolume, snapshots: true }, onChange: onChangeFn }; - const { user } = plainRender(); - const checkbox: HTMLInputElement = screen.getByRole("checkbox"); - await user.click(checkbox); - expect(onChangeFn).toHaveBeenCalledWith({ active: false }); - }); -}); diff --git a/web/src/components/storage/SnapshotsField.tsx b/web/src/components/storage/SnapshotsField.tsx deleted file mode 100644 index 1235e3b81f..0000000000 --- a/web/src/components/storage/SnapshotsField.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @ts-check - -import React from "react"; -import { Split, Switch } from "@patternfly/react-core"; -import { _ } from "~/i18n"; -import { hasFS } from "~/components/storage/utils"; -import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; -import { Volume } from "~/types/storage"; - -export type SnapshotsFieldProps = { - rootVolume: Volume; - onChange?: (config: SnapshotsConfig) => void; -}; - -export type SnapshotsConfig = { - active: boolean; -}; - -/** - * Allows to define snapshots enablement - * @component - */ -export default function SnapshotsField({ rootVolume, onChange }: SnapshotsFieldProps) { - const isChecked = hasFS(rootVolume, "Btrfs") && rootVolume.snapshots; - - const label = _("Use Btrfs snapshots for the root file system"); - - const switchState = () => { - if (onChange) onChange({ active: !isChecked }); - }; - - return ( - - -
-
{label}
-
- {_( - "Allows to boot to a previous version of the \ -system after configuration changes or software upgrades.", - )} -
-
-
- ); -} diff --git a/web/src/components/storage/VolumeDialog.test.tsx b/web/src/components/storage/VolumeDialog.test.tsx deleted file mode 100644 index e249dfd9e9..0000000000 --- a/web/src/components/storage/VolumeDialog.test.tsx +++ /dev/null @@ -1,428 +0,0 @@ -/* - * Copyright (c) [2004] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @ts-check - -import React from "react"; -import { screen, waitFor, within } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import { parseToBytes } from "~/components/storage/utils"; -import VolumeDialog, { VolumeDialogProps } from "./VolumeDialog"; -import { Volume, VolumeTarget } from "~/types/storage"; - -const rootVolume: Volume = { - mountPath: "/", - target: VolumeTarget.DEFAULT, - fsType: "Btrfs", - minSize: 1024, - maxSize: 2048, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: true, - fsTypes: ["Btrfs", "Ext4"], - supportAutoSize: true, - snapshotsConfigurable: true, - snapshotsAffectSizes: true, - sizeRelevantVolumes: [], - adjustByRam: false, - productDefined: true, - }, -}; - -const swapVolume: Volume = { - mountPath: "swap", - target: VolumeTarget.DEFAULT, - fsType: "Swap", - minSize: 1024, - maxSize: 1024, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Swap"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - adjustByRam: false, - sizeRelevantVolumes: [], - productDefined: true, - }, -}; - -const homeVolume: Volume = { - mountPath: "/home", - target: VolumeTarget.DEFAULT, - fsType: "XFS", - minSize: 1024, - maxSize: 4096, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - adjustByRam: false, - sizeRelevantVolumes: [], - productDefined: true, - }, -}; - -const arbitraryVolume: Volume = { - mountPath: "", - target: VolumeTarget.DEFAULT, - fsType: "XFS", - minSize: 1024, - maxSize: 4096, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - adjustByRam: false, - sizeRelevantVolumes: [], - productDefined: false, - }, -}; - -let props: VolumeDialogProps; - -describe("VolumeDialog", () => { - beforeEach(() => { - props = { - volume: undefined, - volumes: [], - templates: [], - isOpen: true, - onCancel: jest.fn(), - onAccept: jest.fn(), - }; - }); - - describe("when adding a new volume", () => { - describe("predefined by the product", () => { - it("does not allow settings the mount point", () => { - plainRender(); - expect(screen.queryByRole("textbox", { name: "Mount point" })).toBeNull(); - }); - }); - - describe("not predefined by the product", () => { - it("allows setting the mount point", async () => { - const { user } = plainRender(); - const mountPointInput = screen.getByRole("textbox", { name: "Mount point" }); - const submit = screen.getByRole("button", { name: "Accept" }); - await user.type(mountPointInput, "/var/log"); - await user.click(submit); - expect(props.onAccept).toHaveBeenCalledWith( - expect.objectContaining({ mountPath: "/var/log" }), - ); - }); - }); - - it("renders a file system picker if it allows more than one", async () => { - const { user } = plainRender(); - const fsTypeButton = screen.getByRole("button", { name: "File system type" }); - await user.click(fsTypeButton); - screen.getByRole("option", { name: "XFS" }); - screen.getByRole("option", { name: "Ext4" }); - }); - - it("does not render a file system picker when it accepts only one", async () => { - plainRender(); - await waitFor(() => - expect(screen.queryByRole("button", { name: "File system type" })).not.toBeInTheDocument(), - ); - }); - - it("renders 'Auto', 'Fixed', and 'Range' size options when volume supports auto size", () => { - plainRender(); - screen.getByRole("radio", { name: "Auto" }); - screen.getByRole("radio", { name: "Fixed" }); - screen.getByRole("radio", { name: "Range" }); - }); - - it("renders only 'Fixed' and 'Range' size options if volume does not support auto size", () => { - plainRender(); - expect(screen.queryByRole("radio", { name: "Auto" })).toBeNull(); - screen.getByRole("radio", { name: "Fixed" }); - screen.getByRole("radio", { name: "Range" }); - }); - - it("uses the min size unit as max size unit when it is missing", () => { - plainRender( - , - ); - const maxSizeUnitSelector = screen.getByRole("combobox", { - name: "Unit for the maximum size", - }); - expect(maxSizeUnitSelector).toHaveValue("TiB"); - }); - }); - - describe("when editing a volume", () => { - beforeEach(() => { - props = { ...props, volumes: [rootVolume, homeVolume, swapVolume, arbitraryVolume] }; - }); - - it("does not allow changing the mount point", () => { - const { rerender } = plainRender(); - expect(screen.queryByRole("textbox", { name: "Mount point" })).toBeNull(); - rerender(); - expect(screen.queryByRole("textbox", { name: "Mount point" })).toBeNull(); - }); - - it("renders a file system picker if it allows more than one", async () => { - const { user } = plainRender(); - const fsTypeButton = screen.getByRole("button", { name: "File system type" }); - await user.click(fsTypeButton); - screen.getByRole("option", { name: "XFS" }); - screen.getByRole("option", { name: "Ext4" }); - }); - - it("does not render a file system picker when it accepts only one", async () => { - plainRender(); - await waitFor(() => - expect(screen.queryByRole("button", { name: "File system type" })).not.toBeInTheDocument(), - ); - }); - - it("renders 'Auto', 'Fixed', and 'Range' size options when volume supports auto size", () => { - plainRender(); - screen.getByRole("radio", { name: "Auto" }); - screen.getByRole("radio", { name: "Fixed" }); - screen.getByRole("radio", { name: "Range" }); - }); - - it("renders only 'Fixed' and 'Range' size options if volume does not support auto size", () => { - plainRender(); - expect(screen.queryByRole("radio", { name: "Auto" })).toBeNull(); - screen.getByRole("radio", { name: "Fixed" }); - screen.getByRole("radio", { name: "Range" }); - }); - - it("uses the min size unit as max size unit when it is missing", () => { - plainRender( - , - ); - const maxSizeUnitSelector = screen.getByRole("combobox", { - name: "Unit for the maximum size", - }); - expect(maxSizeUnitSelector).toHaveValue("TiB"); - }); - }); - - it("calls the onAccept callback with resulting volume when the form is submitted", async () => { - const { user } = plainRender(); - const submit = screen.getByRole("button", { name: "Accept" }); - 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: "Unit for the minimum size" }); - const minSizeGiBUnit = within(minSizeUnitSelector).getByRole("option", { name: "GiB" }); - const maxSizeInput = screen.getByRole("textbox", { name: "Maximum desired size" }); - const maxSizeUnitSelector = screen.getByRole("combobox", { name: "Unit for the maximum size" }); - 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(submit); - - expect(props.onAccept).toHaveBeenCalledWith({ - ...rootVolume, - autoSize: false, - minSize: parseToBytes("10 GiB"), - maxSize: parseToBytes("25 GiB"), - }); - }); - - it("does not call the onAccept callback when the form is not submitted", async () => { - const { user } = plainRender(); - const cancelButton = screen.getByRole("button", { name: "Cancel" }); - await user.click(cancelButton); - expect(props.onAccept).not.toHaveBeenCalled(); - expect(props.onCancel).toHaveBeenCalled(); - }); - - describe("mount point validations", () => { - it("warns and helps user when entered mount path included in a not existing but predefined volume", async () => { - const { user } = plainRender( - , - ); - const mountPointInput = screen.getByRole("textbox", { name: "Mount point" }); - await user.type(mountPointInput, "/home"); - await screen.findByText("There is a predefined file system for /home."); - const addButton = screen.getByRole("button", { name: "Do you want to add it?" }); - await user.click(addButton); - screen.getByRole("heading", { name: "Add /home file system" }); - expect(screen.queryByRole("textbox", { name: "Mount point" })).toBeNull(); - screen.getByText("/home"); - }); - - it("warns and helps user when entered mount path including in an existing volume", async () => { - const { user } = plainRender( - , - ); - const mountPointInput = screen.getByRole("textbox", { name: "Mount point" }); - await user.type(mountPointInput, "swap"); - await screen.findByText("There is already a file system for swap."); - const editButton = screen.getByRole("button", { name: "Do you want to edit it?" }); - await user.click(editButton); - screen.getByRole("heading", { name: "Edit swap file system" }); - expect(screen.queryByRole("textbox", { name: "Mount point" })).toBeNull(); - screen.getByText("swap"); - }); - - it("renders an error if a not valid path was given", async () => { - const { user } = plainRender(); - const mountPointInput = screen.getByRole("textbox", { name: "Mount point" }); - const submitButton = screen.getByRole("button", { name: "Accept" }); - - // No mount point given - await user.click(submitButton); - screen.getByText("A mount point is required"); - - // Without starting backslash - await user.clear(mountPointInput); - await user.type(mountPointInput, "var/log"); - await user.click(submitButton); - screen.getByText("The mount point is invalid"); - - // With more than one leading backslash - await user.clear(mountPointInput); - await user.type(mountPointInput, "//var/log/"); - await user.click(submitButton); - screen.getByText("The mount point is invalid"); - - // With more than one trailing backslash - await user.clear(mountPointInput); - await user.type(mountPointInput, "/var/log//"); - await user.click(submitButton); - screen.getByText("The mount point is invalid"); - }); - }); - - describe("size validations", () => { - describe("when 'Fixed' size is selected", () => { - it("renders an error when size is not given", async () => { - const { user } = plainRender(); - const submitButton = screen.getByRole("button", { name: "Accept" }); - const fixedSizeOption = screen.getByRole("radio", { name: "Fixed" }); - await user.click(fixedSizeOption); - const sizeInput = screen.getByRole("textbox", { name: "Exact size" }); - await user.clear(sizeInput); - await user.click(submitButton); - screen.getByText("A size value is required"); - await user.type(sizeInput, "10"); - await user.click(submitButton); - expect(screen.queryByText("A size value is required")).toBeNull(); - }); - }); - - describe("when 'Range' size is selected", () => { - it("renders an error when min size is not given", async () => { - const { user } = plainRender(); - - const submitButton = screen.getByRole("button", { name: "Accept" }); - 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(submitButton); - screen.getByText("Minimum size is required"); - - await user.type(minSizeInput, "10"); - await user.click(submitButton); - expect(screen.queryByText("Minimum size is required")).toBeNull(); - }); - - it("renders an error when max size is smaller than or equal to min size", async () => { - const volume = { ...homeVolume, minSize: undefined, maxSize: undefined }; - const { user } = plainRender(); - const submitButton = screen.getByRole("button", { name: "Accept" }); - 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: "Unit for the minimum size", - }); - const minSizeMiBUnit = within(minSizeUnitSelector).getByRole("option", { name: "MiB" }); - const maxSizeInput = screen.getByRole("textbox", { name: "Maximum desired size" }); - const maxSizeUnitSelector = screen.getByRole("combobox", { - name: "Unit for the maximum size", - }); - 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(submitButton); - 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(submitButton); - 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(submitButton); - 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(submitButton); - 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); - }); - }); - }); -}); diff --git a/web/src/components/storage/VolumeDialog.tsx b/web/src/components/storage/VolumeDialog.tsx deleted file mode 100644 index 56b71d16d4..0000000000 --- a/web/src/components/storage/VolumeDialog.tsx +++ /dev/null @@ -1,647 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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, { FormEvent, useReducer } from "react"; -import { Alert, Button, Form, Split } from "@patternfly/react-core"; -import { Popup } from "~/components/core"; -import { FsField, MountPathField, SizeOptionsField } from "~/components/storage/VolumeFields"; -import { _ } from "~/i18n"; -import { sprintf } from "sprintf-js"; -import { compact, useDebounce } from "~/utils"; -import { - SizeMethod, - DEFAULT_SIZE_UNIT, - SIZE_METHODS, - mountFilesystem, - parseToBytes, - reuseDevice, - splitSize, - volumeLabel, -} from "~/components/storage/utils"; -import { Volume } from "~/types/storage"; - -type VolumeFormState = { - volume: Volume; - formData: VolumeFormData; - errors: VolumeFormErrors; -}; -type VolumeFormData = { - minSize?: number | string; - minSizeUnit?: string; - maxSize?: number | string; - maxSizeUnit?: string; - sizeMethod: SizeMethod; - mountPath: string; - fsType: string; - snapshots: boolean; -}; -type VolumeFormErrors = { - missingMountPath: string | null; - invalidMountPath: string | null; - existingVolume: React.ReactElement | null; - existingTemplate: React.ReactElement | null; - missingSize: string | null; - missingMinSize: string | null; - invalidMaxSize: string | null; -}; - -/** - * Renders the title for the dialog. - */ -const renderTitle = (volume: Volume, volumes: Volume[]): string => { - const isNewVolume = !volumes.includes(volume); - const isProductDefined = volume.outline.productDefined; - const label = volumeLabel(volume); - - if (isNewVolume && isProductDefined) return sprintf(_("Add %s file system"), label); - if (!isNewVolume && isProductDefined) return sprintf(_("Edit %s file system"), label); - - return isNewVolume ? _("Add file system") : _("Edit file system"); -}; - -/** - * @component - */ -const VolumeAlert = ({ volume }: { volume: Volume }) => { - let alert: { title: string; text: string }; - - if (mountFilesystem(volume)) { - alert = { - // TRANSLATORS: Warning when editing a file system. - title: _("The type and size of the file system cannot be edited."), - // TRANSLATORS: Description of a warning. The first %s is replaced by a device name (e.g., - // /dev/vda) and the second %s is replaced by a mount path (e.g., /home). - text: sprintf( - _("The current file system on %s is selected to be mounted at %s."), - volume.targetDevice.name, - volume.mountPath, - ), - }; - } else if (reuseDevice(volume)) { - alert = { - // TRANSLATORS: Warning when editing a file system. - title: _("The size of the file system cannot be edited"), - // TRANSLATORS: Description of a warning. %s is replaced by a device name (e.g., /dev/vda). - text: sprintf(_("The file system is allocated at the device %s."), volume.targetDevice.name), - }; - } - - if (!alert) return null; - - return ( - - {alert.text} - - ); -}; - -/** @fixme Redesign *Error classes. - * - * Having different *Error classes does not seem to be a good design. Note these classes do not - * represent an error but a helper to check and render an error. It would be a better approach to - * have something like a volume checker which generates errors: - * - * For example: - * - * const checker = new VolumeChecker(volume, volumes, templates); - * const error = checker.existingMountPathError(); - * const message = error?.render(onClick); - */ -class MissingMountPathError { - mountPath: string; - - constructor(mountPath: string) { - this.mountPath = mountPath; - } - - check(): boolean { - return this.mountPath.length === 0; - } - - render(): string { - return _("A mount point is required"); - } -} - -class InvalidMountPathError { - mountPath: string; - - constructor(mountPath: string) { - this.mountPath = mountPath; - } - - check(): boolean { - const regex = /^swap$|^\/$|^(\/[^/\s]+([^/]*[^/\s])*)+$/; - return !regex.test(this.mountPath); - } - - render(): string { - return _("The mount point is invalid"); - } -} - -class MissingSizeError { - sizeMethod: SizeMethod; - size: string | number; - - constructor(sizeMethod: SizeMethod, size: string | number) { - this.sizeMethod = sizeMethod; - this.size = size; - } - - check(): boolean { - return this.sizeMethod === SIZE_METHODS.MANUAL && !this.size; - } - - render(): string { - return _("A size value is required"); - } -} - -class MissingMinSizeError { - sizeMethod: SizeMethod; - minSize: string | number; - - constructor(sizeMethod: SizeMethod, minSize: string | number) { - this.sizeMethod = sizeMethod; - this.minSize = minSize; - } - - check(): boolean { - return this.sizeMethod === SIZE_METHODS.RANGE && !this.minSize; - } - - render(): string { - return _("Minimum size is required"); - } -} - -class InvalidMaxSizeError { - sizeMethod: SizeMethod; - minSize: string | number; - maxSize: string | number; - - constructor(sizeMethod: SizeMethod, minSize: string | number, maxSize: string | number) { - this.sizeMethod = sizeMethod; - this.minSize = minSize; - this.maxSize = maxSize; - } - - check(): boolean { - return ( - this.sizeMethod === SIZE_METHODS.RANGE && this.maxSize !== -1 && this.maxSize <= this.minSize - ); - } - - render(): string { - return _("Maximum must be greater than minimum"); - } -} - -class ExistingVolumeError { - mountPath: string; - volumes: Volume[]; - - constructor(mountPath: string, volumes: Volume[]) { - this.mountPath = mountPath; - this.volumes = volumes; - } - - findVolume(): Volume | undefined { - return this.volumes.find((t) => t.mountPath === this.mountPath); - } - - check(): boolean { - return this.mountPath.length && this.findVolume() !== undefined; - } - - render(onClick: (volume: Volume) => void): React.ReactElement { - const volume = this.findVolume(); - const path = this.mountPath === "/" ? "root" : this.mountPath; - - return ( - - {sprintf(_("There is already a file system for %s."), path)} - - - ); - } -} - -class ExistingTemplateError { - mountPath: string; - templates: Volume[]; - - constructor(mountPath: string, templates: Volume[]) { - this.mountPath = mountPath; - this.templates = templates; - } - - findTemplate(): Volume | undefined { - return this.templates.find((t) => t.mountPath === this.mountPath); - } - - check(): boolean { - return this.mountPath.length && this.findTemplate() !== undefined; - } - - render(onClick: (template: Volume) => void): React.ReactElement { - const template = this.findTemplate(); - const path = this.mountPath === "/" ? "root" : this.mountPath; - - return ( - - {sprintf(_("There is a predefined file system for %s."), path)} - - - ); - } -} - -/** - * Error if the mount path is missing. - */ -const missingMountPathError = (mountPath: string): string | null => { - const error = new MissingMountPathError(mountPath); - return error.check() ? error.render() : null; -}; - -/** - * Error if the mount path is not valid. - */ -const invalidMountPathError = (mountPath: string): string | null => { - const error = new InvalidMountPathError(mountPath); - return error.check() ? error.render() : null; -}; - -/** - * Error if the size is missing. - */ -const missingSizeError = (sizeMethod: SizeMethod, size: string | number): string | null => { - const error = new MissingSizeError(sizeMethod, size); - return error.check() ? error.render() : null; -}; - -/** - * Error if the min size is missing. - */ -const missingMinSizeError = (sizeMethod: SizeMethod, minSize: string | number): string | null => { - const error = new MissingMinSizeError(sizeMethod, minSize); - return error.check() ? error.render() : null; -}; - -/** - * Error if the max size is not valid. - */ -const invalidMaxSizeError = ( - sizeMethod: SizeMethod, - minSize: string | number, - maxSize: string | number, -): string | null => { - const error = new InvalidMaxSizeError(sizeMethod, minSize, maxSize); - return error.check() ? error.render() : null; -}; - -/** - * Error if the given mount path exists in the list of volumes. - */ -const existingVolumeError = ( - mountPath: string, - volumes: Volume[], - onClick: (volume: Volume) => void, -): React.ReactElement | null => { - const error = new ExistingVolumeError(mountPath, volumes); - return error.check() ? error.render(onClick) : null; -}; - -/** - * Error if the given mount path exists in the list of templates. - */ -const existingTemplateError = ( - mountPath: string, - templates: Volume[], - onClick: (template: Volume) => void, -): React.ReactElement | null => { - const error = new ExistingTemplateError(mountPath, templates); - return error.check() ? error.render(onClick) : null; -}; - -/** - * Checks whether there is any error. - */ -const anyError = (errors: VolumeFormErrors): boolean => { - return compact(Object.values(errors)).length > 0; -}; - -/** - * Remove leftover trailing slash. - */ -const sanitizeMountPath = (mountPath: string): string => { - if (mountPath === "/") return mountPath; - - return mountPath.replace(/\/$/, ""); -}; - -/** - * Creates a new storage volume object based on given params. - */ -const createUpdatedVolume = (volume: Volume, formData: VolumeFormData): Volume => { - let sizeAttrs = {}; - const minSize = parseToBytes(`${formData.minSize} ${formData.minSizeUnit}`); - const maxSize = parseToBytes(`${formData.maxSize} ${formData.maxSizeUnit}`); - - switch (formData.sizeMethod) { - case SIZE_METHODS.AUTO: - sizeAttrs = { minSize: undefined, maxSize: undefined, autoSize: true }; - break; - case SIZE_METHODS.MANUAL: - sizeAttrs = { minSize, maxSize: minSize, autoSize: false }; - break; - case SIZE_METHODS.RANGE: - sizeAttrs = { minSize, maxSize: formData.maxSize ? maxSize : undefined, autoSize: false }; - break; - } - - const { fsType, snapshots } = formData; - const mountPath = sanitizeMountPath(formData.mountPath); - - return { ...volume, mountPath, ...sizeAttrs, fsType, snapshots }; -}; - -/** - * Form-related helper for guessing the size method for given volume - */ -const sizeMethodFor = (volume: Volume): SizeMethod => { - const { autoSize, minSize, maxSize } = volume; - - if (autoSize) { - 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 - */ -const prepareFormData = (volume: Volume): VolumeFormData => { - const { size: minSize = "", unit: minSizeUnit = DEFAULT_SIZE_UNIT } = splitSize(volume.minSize); - const { size: maxSize = "", unit: maxSizeUnit = minSizeUnit || DEFAULT_SIZE_UNIT } = splitSize( - volume.maxSize, - ); - - return { - minSize, - minSizeUnit, - maxSize, - maxSizeUnit, - sizeMethod: sizeMethodFor(volume), - mountPath: volume.mountPath, - fsType: volume.fsType, - snapshots: volume.snapshots, - }; -}; - -/** - * Possible errors from the form data. - */ -const prepareErrors = (): VolumeFormErrors => { - return { - missingMountPath: null, - invalidMountPath: null, - existingVolume: null, - existingTemplate: null, - missingSize: null, - missingMinSize: null, - invalidMaxSize: null, - }; -}; - -/** - * Initializer function for the React#useReducer used in the {@link VolumesForm} - * - * @param volume - a storage volume object - */ -const createInitialState = (volume: Volume): VolumeFormState => { - const formData = prepareFormData(volume); - const errors = prepareErrors(); - - return { volume, formData, errors }; -}; - -/** - * The VolumeForm reducer. - */ -const reducer = (state: VolumeFormState, action: { type: string; payload }) => { - 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": { - const errors = { ...state.errors, ...payload }; - return { ...state, errors }; - } - - default: { - return state; - } - } -}; - -export type VolumeDialogProps = { - volume: Volume; - volumes: Volume[]; - templates: Volume[]; - isOpen?: boolean; - onCancel: () => void; - onAccept: (volume: Volume) => void; -}; - -/** - * Renders a dialog that allows the user to add or edit a file system. - * @component - */ -export default function VolumeDialog({ - volume: currentVolume, - volumes, - templates, - isOpen, - onCancel, - onAccept, -}: VolumeDialogProps) { - const [state, dispatch]: [VolumeFormState, (action) => void] = useReducer( - reducer, - currentVolume, - createInitialState, - ); - - const delayed: Function = useDebounce((f) => f(), 1000); - - const changeVolume: (volume: Volume) => void = (volume) => { - dispatch({ type: "CHANGE_VOLUME", payload: { volume } }); - }; - - const updateData: (data: object) => void = (data): void => - dispatch({ type: "UPDATE_DATA", payload: data }); - - const updateErrors: (errors: object) => void = (errors): void => - dispatch({ type: "SET_ERRORS", payload: errors }); - - const mountPathError: () => string | React.ReactElement = () => { - const { missingMountPath, invalidMountPath, existingVolume, existingTemplate } = state.errors; - return missingMountPath || invalidMountPath || existingVolume || existingTemplate; - }; - - const sizeErrors: () => object = () => { - return { - size: state.errors.missingSize, - minSize: state.errors.missingMinSize, - maxSize: state.errors.invalidMaxSize, - }; - }; - - const disableWidgets: () => boolean = () => { - const { existingVolume, existingTemplate } = state.errors; - return existingVolume !== null || existingTemplate !== null; - }; - - const isMountPathEditable: () => boolean = () => { - const isNewVolume = !volumes.includes(state.volume); - const isPredefined = state.volume.outline.productDefined; - return isNewVolume && !isPredefined; - }; - - const changeMountPath: (mountPath: string) => void = (mountPath) => { - // Reset current errors. - const errors = { - missingMountPath: null, - invalidMountPath: null, - existingVolume: null, - existingTemplate: null, - }; - updateErrors(errors); - - delayed(() => { - // Reevaluate in a delayed way. - const errors = { - existingVolume: existingVolumeError(mountPath, volumes, changeVolume), - existingTemplate: existingTemplateError(mountPath, templates, changeVolume), - }; - updateErrors(errors); - }); - - updateData({ mountPath }); - }; - - const changeSizeOptions: (data: object) => void = (data) => { - // Reset errors. - const errors = { - missingSize: null, - missingMinSize: null, - invalidMaxSize: null, - }; - updateErrors(errors); - updateData(data); - }; - - const submitForm: (e: FormEvent) => void = (e) => { - e.preventDefault(); - const { volume: originalVolume, formData } = state; - const volume = createUpdatedVolume(originalVolume, formData); - - const checkMountPath = isMountPathEditable(); - - const errors = { - missingMountPath: checkMountPath ? missingMountPathError(volume.mountPath) : null, - invalidMountPath: checkMountPath ? invalidMountPathError(volume.mountPath) : null, - existingVolume: checkMountPath - ? existingVolumeError(volume.mountPath, volumes, changeVolume) - : null, - existingTemplate: checkMountPath - ? existingTemplateError(volume.mountPath, templates, changeVolume) - : null, - missingSize: missingSizeError(formData.sizeMethod, volume.minSize), - missingMinSize: missingMinSizeError(formData.sizeMethod, volume.minSize), - invalidMaxSize: invalidMaxSizeError(formData.sizeMethod, volume.minSize, volume.maxSize), - }; - - anyError(errors) ? updateErrors(errors) : onAccept(volume); - }; - - const title = renderTitle(state.volume, volumes); - const { fsType, mountPath } = state.formData; - const isDisabled = disableWidgets(); - const isFsFieldDisabled = isDisabled || mountFilesystem(state.volume); - const isSizeFieldDisabled = isDisabled || reuseDevice(state.volume); - - return ( - /** @fixme blockSize medium is too big and small is too small. */ - -
- - - - - - - - {_("Accept")} - - - -
- ); -} diff --git a/web/src/components/storage/VolumeFields.test.tsx b/web/src/components/storage/VolumeFields.test.tsx deleted file mode 100644 index e706c9686a..0000000000 --- a/web/src/components/storage/VolumeFields.test.tsx +++ /dev/null @@ -1,191 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 { SIZE_METHODS } from "~/components/storage/utils"; -import { FsField, MountPathField, SizeOptionsField } from "~/components/storage/VolumeFields"; -import { Volume, VolumeTarget } from "~/types/storage"; - -const volume: Volume = { - mountPath: "/home", - target: VolumeTarget.DEFAULT, - fsType: "XFS", - minSize: 1024, - maxSize: 4096, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Ext4", "XFS"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - adjustByRam: false, - sizeRelevantVolumes: [], - productDefined: true, - }, -}; - -const callbackFn = jest.fn(); - -describe("MountPathField", () => { - it("renders a text input with given value", () => { - const { rerender } = plainRender(); - const input = screen.getByRole("textbox", { name: "Mount point" }); - expect(input).toHaveValue("/home"); - rerender(); - expect(input).toHaveValue("/var"); - }); - - it("renders given error", () => { - plainRender( - , - ); - screen.getByText("Something went wrong"); - }); - - it("calls given callback when user changes its content", async () => { - const { user } = plainRender(); - const input = screen.getByRole("textbox", { name: "Mount point" }); - // NOTE: MountPathField is a controlled component. That makes a bit more - // difficult to write more sensible test here by typing "/var" and expecting that - // callback is called multiple times with "/", "/v", "/va", and "/var". It - // will not work because it's actually triggering the onChange with "/", - // "v", "a", and "r", but having a different "value" each time it's - // re-rendered. Anyway, checking that callback is called is enough. - await user.type(input, "/"); - expect(callbackFn).toHaveBeenCalledWith("/"); - }); - - it("renders only the value if mount as read-only (no input)", () => { - plainRender(); - expect(screen.queryByRole("textbox", { name: "Mount point" })).toBeNull(); - screen.getByText("/home"); - }); -}); - -describe("SizeOptionsField", () => { - it("renders radio group with sizing options", () => { - plainRender( - , - ); - screen.getByRole("radiogroup", { name: "Size" }); - }); - - it("renders 'Fixed' and 'Range' options always but 'Auto' only if volume accepts auto size", () => { - const { rerender } = plainRender( - , - ); - const group = screen.getByRole("radiogroup", { name: "Size" }); - within(group).getByRole("radio", { name: "Fixed" }); - within(group).getByRole("radio", { name: "Range" }); - expect(within(group).queryByRole("radio", { name: "Auto" })).toBeNull(); - - rerender( - , - ); - within(group).getByRole("radio", { name: "Auto" }); - within(group).getByRole("radio", { name: "Fixed" }); - within(group).getByRole("radio", { name: "Range" }); - }); - - it("renders options as disabled when isDisabled props is given", () => { - plainRender( - , - ); - const group = screen.getByRole("radiogroup", { name: "Size" }); - within(group) - .getAllByRole("radio") - .forEach((option) => expect(option).toBeDisabled()); - }); - - it("calls given callback when user changes selected option", async () => { - const { user } = plainRender( - , - ); - const group = screen.getByRole("radiogroup", { name: "Size" }); - const rangeOption = within(group).getByRole("radio", { name: "Range" }); - await user.click(rangeOption); - expect(callbackFn).toHaveBeenCalledWith({ sizeMethod: SIZE_METHODS.RANGE }); - }); - - it.todo("test SizeAuto internal component"); - it.todo("test SizeManual internal component"); - it.todo("test SizeRange internal component"); -}); - -describe("FsField", () => { - it("renders control for selecting a file system, with the given value as initial selection", async () => { - const { user, rerender } = plainRender( - , - ); - let button = screen.getByRole("button", { name: "File system type" }); - await user.click(button); - const xfsOption = screen.getByRole("option", { name: "XFS" }); - let ext4Option = screen.getByRole("option", { name: "Ext4" }); - expect(xfsOption).toHaveAttribute("aria-selected", "true"); - expect(ext4Option).toHaveAttribute("aria-selected", "false"); - expect(screen.queryByRole("option", { name: "Btrfs" })).toBeNull(); - - rerender( - , - ); - button = screen.getByRole("button", { name: "File system type" }); - await user.click(button); - ext4Option = screen.getByRole("option", { name: "Ext4" }); - const btrfsOption = screen.getByRole("option", { name: "Btrfs" }); - expect(ext4Option).toHaveAttribute("aria-selected", "true"); - expect(btrfsOption).toHaveAttribute("aria-selected", "false"); - expect(screen.queryByRole("option", { name: "XFS" })).toBeNull(); - }); - - it("renders control as disabled when isDisabled is given", () => { - plainRender(); - const button = screen.getByRole("button", { name: "File system type" }); - expect(button).toBeDisabled(); - }); - - it("calls given callback when user clicks on an option", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: "File system type" }); - await user.click(button); - const ext4Option = screen.getByRole("option", { name: "Ext4" }); - await user.click(ext4Option); - expect(callbackFn).toHaveBeenCalledWith(expect.objectContaining({ fsType: "Ext4" })); - }); -}); diff --git a/web/src/components/storage/VolumeFields.tsx b/web/src/components/storage/VolumeFields.tsx deleted file mode 100644 index aafe93e25b..0000000000 --- a/web/src/components/storage/VolumeFields.tsx +++ /dev/null @@ -1,554 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 { - FormGroup, - FormSelect, - FormSelectOption, - InputGroup, - InputGroupItem, - MenuToggle, - Popover, - Radio, - Select, - SelectOption, - SelectList, - Split, - Stack, - TextInput, - FormSelectProps, -} from "@patternfly/react-core"; -import { FormValidationError, FormReadOnlyField, NumericTextInput } from "~/components/core"; -import { Icon } from "~/components/layout"; -import { _, N_ } from "~/i18n"; -import { sprintf } from "sprintf-js"; -import { SIZE_METHODS, SIZE_UNITS } from "~/components/storage/utils"; -import { Volume } from "~/types/storage"; - -const { K, ...MAX_SIZE_UNITS } = SIZE_UNITS; - -export type MountPathFieldProps = { - value?: string; - isReadOnly?: boolean; - onChange: (mountPath: string) => void; - error?: React.ReactNode; -}; - -/** - * Field for the mount path of a volume. - * @component - */ -const MountPathField = ({ - value = "", - onChange, - isReadOnly = false, - error, -}: MountPathFieldProps) => { - const label = _("Mount point"); - - const changeMountPath: (_, mountPath: string) => void = (_, mountPath) => onChange(mountPath); - - if (isReadOnly) { - return {value}; - } - - return ( - - - - - ); -}; - -/** - * Form control for selecting a size unit - * @component - * - * Based on {@link PF/FormSelect https://www.patternfly.org/components/forms/form-select} - * - * @param {object} props - * @param props.units - a collection of size units - * @param props.formSelectProps - */ -const SizeUnitFormSelect = ({ - units, - ...formSelectProps -}: { - units: Array; - formSelectProps: FormSelectProps; -}) => { - return ( - - {units.map((unit) => { - // unit values are marked for translation in the utils.js file - // eslint-disable-next-line agama-i18n/string-literals - return ; - })} - - ); -}; - -/** - * Possible file system type options for a volume. - */ -const fsOptions = (volume: Volume): string[] => { - return volume.outline.fsTypes; -}; - -/** - * Option for selecting a file system type. - * @component - */ -const FsSelectOption = ({ fsOption }: { fsOption: string }) => { - return ( - - {fsOption} - - ); -}; - -/** - * Widget for selecting a file system type. - * @component - * - * @param props - * @param props.id - Widget id. - * @param props.value - Currently selected file system. - * @param props.volume - The selected storage volume. - * @param props.isDisabled - * @param props.onChange - Callback for notifying input changes. - */ -const FsSelect = ({ - id, - value, - volume, - isDisabled, - onChange, -}: { - id: string; - value: string; - volume: Volume; - isDisabled: boolean; - onChange: (data: object) => void; -}) => { - const [isOpen, setIsOpen] = useState(false); - - const options = fsOptions(volume); - const selected = value; - - const onToggleClick = () => { - setIsOpen(!isOpen); - }; - - const onSelect = (_event, option) => { - setIsOpen(false); - onChange({ fsType: option }); - }; - - const toggle = (toggleRef) => { - return ( - - {selected} - - ); - }; - - return ( - - ); -}; - -type FsFieldProps = { - value: string; - volume: Volume; - isDisabled?: boolean; - onChange: (data: object) => void; -}; - -/** - * Widget for rendering the file system configuration. - * - * Allows selecting a file system type. If there is only one possible option, then it renders plain - * text with the unique option. - * @component - */ -const FsField = ({ value, volume, isDisabled = false, onChange }: FsFieldProps) => { - const isSingleFs = () => { - // check for btrfs with snapshots - if (volume.fsType === "Btrfs" && volume.snapshots) { - return true; - } - - const { fsTypes } = volume.outline; - return fsTypes.length === 1; - }; - - const Info = () => { - // TRANSLATORS: info about possible file system types. - const text = _( - "The options for the file system type depends on the product and the mount point.", - ); - - return ( - - - - ); - }; - - // TRANSLATORS: label for the file system selector. - const label = _("File system type"); - - if (isSingleFs()) { - return {value}; - } - - return ( - } fieldId="fsType"> - - - ); -}; - -/** - * Widget for rendering the size option content when SIZE_UNITS.AUTO is selected - * @component - * - * @param {object} props - * @param {Volume} props.volume - a storage volume object - */ -const SizeAuto = ({ volume }: { volume: Volume }) => { - const conditions = []; - - if (volume.outline.snapshotsAffectSizes) - // TRANSLATORS: item which affects the final computed partition size - conditions.push(_("the configuration of snapshots")); - - if (volume.outline.sizeRelevantVolumes && volume.outline.sizeRelevantVolumes.length > 0) - // TRANSLATORS: item which affects the final computed partition size - // %s is replaced by a list of mount points like "/home, /boot" - conditions.push( - sprintf( - _("the presence of the file system for %s"), - // TRANSLATORS: conjunction for merging two list items - volume.outline.sizeRelevantVolumes.join(_(", ")), - ), - ); - - if (volume.outline.adjustByRam) - // TRANSLATORS: item which affects the final computed partition size - conditions.push(_("the amount of RAM in the system")); - - // TRANSLATORS: the %s is replaced by the items which affect the computed size - const conditionsText = sprintf( - _("The final size depends on %s."), - // TRANSLATORS: conjunction for merging two texts - conditions.join(_(" and ")), - ); - - return ( - <> - {/* TRANSLATORS: the partition size is automatically computed */} -

- {_("Automatically calculated size according to the selected product.")} {conditionsText} -

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

{_("Exact size for the file system.")}

- - - - onChange({ minSize })} - validated={errors.minSize ? "error" : "default"} - isDisabled={isDisabled} - /> - - - onChange({ minSizeUnit })} - isDisabled={isDisabled} - /> - - - - -
- ); -}; - -/** - * Widget for rendering the size option content when SIZE_UNITS.RANGE is selected - * @component - * - * @param props - * @param props.errors - the form errors - * @param props.formData - the form data - * @param props.isDisabled - * @param props.onChange - callback for notifying input changes - */ -const SizeRange = ({ - errors, - formData, - isDisabled, - onChange, -}: { - errors; - formData; - isDisabled: boolean; - onChange: (v: object) => void; -}) => { - return ( - -

- {_( - "Limits for the file system size. The final size will be a value between the given minimum \ -and maximum. If no maximum is given then the file system will be as big as possible.", - )} -

- - - - - onChange({ minSize })} - validated={errors.minSize ? "error" : "default"} - isDisabled={isDisabled} - /> - - - onChange({ minSizeUnit })} - isDisabled={isDisabled} - /> - - - - - - - - onChange({ maxSize })} - isDisabled={isDisabled} - /> - - - onChange({ maxSizeUnit })} - isDisabled={isDisabled} - /> - - - - - -
- ); -}; - -// constants need to be marked for translation with N_() and translated with _() later -const SIZE_OPTION_LABELS = Object.freeze({ - // TRANSLATORS: radio button label, fully automatically computed partition size, no user input - auto: N_("Auto"), - // TRANSLATORS: radio button label, exact partition size requested by user - fixed: N_("Fixed"), - // TRANSLATORS: radio button label, automatically computed partition size within the user provided min and max limits - range: N_("Range"), -}); - -/** - * Widget for rendering the volume size options - * @component - */ - -type SizeOptionsFieldProps = { - volume: Volume; - formData; - errors?: object; - isDisabled?: boolean; - onChange: (v: object) => void; -}; - -const SizeOptionsField = ({ - volume, - formData, - isDisabled = false, - errors = {}, - onChange, -}: SizeOptionsFieldProps) => { - const { sizeMethod } = formData; - const sizeWidgetProps = { errors, formData, volume, isDisabled, onChange }; - - const sizeOptions: string[] = [SIZE_METHODS.MANUAL, SIZE_METHODS.RANGE]; - - if (volume.outline.supportAutoSize) sizeOptions.push(SIZE_METHODS.AUTO); - - return ( - -
- - {sizeOptions.map((value) => { - const isSelected = sizeMethod === value; - - return ( - onChange({ sizeMethod: value })} - isDisabled={isDisabled} - /> - ); - })} - - -
- {sizeMethod === SIZE_METHODS.AUTO && } - {sizeMethod === SIZE_METHODS.RANGE && } - {sizeMethod === SIZE_METHODS.MANUAL && } -
-
-
- ); -}; - -export { FsField, MountPathField, SizeOptionsField }; diff --git a/web/src/components/storage/VolumeLocationDialog.test.tsx b/web/src/components/storage/VolumeLocationDialog.test.tsx deleted file mode 100644 index f89469f858..0000000000 --- a/web/src/components/storage/VolumeLocationDialog.test.tsx +++ /dev/null @@ -1,237 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 VolumeLocationDialog, { - VolumeLocationDialogProps, -} from "~/components/storage/VolumeLocationDialog"; -import { StorageDevice, Volume, VolumeTarget } from "~/types/storage"; - -const sda: StorageDevice = { - sid: 59, - isDrive: true, - type: "disk", - description: "", - 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, - shrinking: { unsupported: ["Resizing is not supported"] }, - 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: StorageDevice = { - sid: 69, - name: "/dev/sda1", - description: "", - isDrive: false, - type: "partition", - size: 256, - filesystem: { - sid: 169, - type: "Swap", - }, -}; - -const sda2: StorageDevice = { - sid: 79, - name: "/dev/sda2", - description: "", - isDrive: false, - type: "partition", - size: 512, - filesystem: { - sid: 179, - type: "Ext4", - }, -}; - -sda.partitionTable = { - type: "gpt", - partitions: [sda1, sda2], - unpartitionedSize: 0, - unusedSlots: [], -}; - -const sdb: StorageDevice = { - sid: 62, - isDrive: true, - type: "disk", - description: "", - vendor: "Samsung", - model: "Samsung Evo 8 Pro", - driver: ["ahci"], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sdb", - size: 2048, - shrinking: { unsupported: ["Resizing is not supported"] }, - systems: [], - udevIds: [], - udevPaths: ["pci-0000:00-19"], -}; - -const volume: Volume = { - mountPath: "/", - target: VolumeTarget.DEFAULT, - fsType: "Btrfs", - minSize: 1024, - maxSize: 2048, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: true, - fsTypes: ["Btrfs", "Ext4"], - supportAutoSize: true, - snapshotsConfigurable: true, - snapshotsAffectSizes: true, - sizeRelevantVolumes: [], - adjustByRam: false, - productDefined: true, - }, -}; - -let props: VolumeLocationDialogProps; - -describe("VolumeLocationDialog", () => { - beforeEach(() => { - props = { - isOpen: true, - volume, - volumes: [], - volumeDevices: [sda, sdb], - targetDevices: [sda], - onCancel: jest.fn(), - onAccept: jest.fn(), - }; - }); - - it("offers an option to create a new partition", () => { - plainRender(); - screen.getByRole("radio", { name: "Create a new partition" }); - }); - - it("offers an option to create a dedicated VG", () => { - plainRender(); - screen.getByRole("radio", { name: /Create a dedicated LVM/ }); - }); - - it("offers an option to format the device", () => { - plainRender(); - screen.getByRole("radio", { name: "Format the device" }); - }); - - it("offers an option to mount the file system", () => { - plainRender(); - screen.getByRole("radio", { name: "Mount the file system" }); - }); - - describe("if the selected device cannot be partitioned", () => { - beforeEach(async () => { - const { user } = plainRender(); - const sda1Row = screen.getByRole("row", { name: /sda1/ }); - const sda1Radio = within(sda1Row).getByRole("radio"); - await user.click(sda1Radio); - }); - - it("disables the option for creating a new partition", () => { - const option = screen.getByRole("radio", { name: "Create a new partition" }); - expect(option).toBeDisabled(); - }); - - it("disables the option for creating a dedicated VG", () => { - const option = screen.getByRole("radio", { name: /Create a dedicated LVM/ }); - expect(option).toBeDisabled(); - }); - }); - - describe("if the selected device has not a compatible file system", () => { - beforeEach(async () => { - const { user } = plainRender(); - const sda1Row = screen.getByRole("row", { name: /sda1/ }); - const sda1Radio = within(sda1Row).getByRole("radio"); - await user.click(sda1Radio); - }); - - it("disables the option for mounting the file system", () => { - const option = screen.getByRole("radio", { name: "Mount the file system" }); - expect(option).toBeDisabled(); - }); - }); - - describe("if the selected device has a compatible file system", () => { - beforeEach(async () => { - const { user } = plainRender(); - const sda2Row = screen.getByRole("row", { name: /sda2/ }); - const sda2Radio = within(sda2Row).getByRole("radio"); - await user.click(sda2Radio); - }); - - it("enables the option for mounting the file system", () => { - const option = screen.getByRole("radio", { name: "Mount the file system" }); - expect(option).toBeEnabled(); - }); - }); - - it("calls onAccept with the selected options on accept", async () => { - const { user } = plainRender(); - - const sdbRow = screen.getByRole("row", { name: /sdb/ }); - const sdbRadio = within(sdbRow).getByRole("radio"); - await user.click(sdbRadio); - - const formatRadio = screen.getByRole("radio", { name: /format the device/i }); - await user.click(formatRadio); - - const accept = screen.getByRole("button", { name: "Confirm" }); - await user.click(accept); - - expect(props.onAccept).toHaveBeenCalledWith( - expect.objectContaining({ target: VolumeTarget.DEVICE, targetDevice: sdb }), - ); - }); - - it("does not call onAccept on cancel", async () => { - const { user } = plainRender(); - const cancel = screen.getByRole("button", { name: "Cancel" }); - - await user.click(cancel); - - expect(props.onAccept).not.toHaveBeenCalled(); - }); -}); diff --git a/web/src/components/storage/VolumeLocationDialog.tsx b/web/src/components/storage/VolumeLocationDialog.tsx deleted file mode 100644 index 4d78c854cf..0000000000 --- a/web/src/components/storage/VolumeLocationDialog.tsx +++ /dev/null @@ -1,224 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 { Form, FormGroup, Radio, Stack } from "@patternfly/react-core"; -import { FormReadOnlyField, Popup } from "~/components/core"; -import VolumeLocationSelectorTable from "~/components/storage/VolumeLocationSelectorTable"; -import { _ } from "~/i18n"; -import { sprintf } from "sprintf-js"; -import { deviceChildren, volumeLabel } from "~/components/storage/utils"; -import { StorageDevice, Volume, VolumeTarget } from "~/types/storage"; - -const defaultTarget: (device: StorageDevice | undefined) => VolumeTarget = ( - device, -): VolumeTarget => { - if (["partition", "lvmLv", "md"].includes(device?.type)) return VolumeTarget.DEVICE; - - return VolumeTarget.NEW_PARTITION; -}; - -/** @type {(volume: Volume, device: StorageDevice|undefined) => VolumeTarget[]} */ -const availableTargets: (volume: Volume, device: StorageDevice | undefined) => VolumeTarget[] = ( - volume, - device, -): VolumeTarget[] => { - /** @type {VolumeTarget[]} */ - const targets: VolumeTarget[] = [VolumeTarget.DEVICE]; - - if (device?.isDrive) { - targets.push(VolumeTarget.NEW_PARTITION); - targets.push(VolumeTarget.NEW_VG); - } - - if (device?.filesystem && volume.outline.fsTypes.includes(device.filesystem.type)) - targets.push(VolumeTarget.FILESYSTEM); - - return targets; -}; - -/** @type {(volume: Volume, device: StorageDevice|undefined) => VolumeTarget} */ -const sanitizeTarget: (volume: Volume, device: StorageDevice | undefined) => VolumeTarget = ( - volume, - device, -): VolumeTarget => { - const targets = availableTargets(volume, device); - return targets.includes(volume.target) ? volume.target : defaultTarget(device); -}; - -export type VolumeLocationDialogProps = { - volume: Volume; - volumes: Volume[]; - volumeDevices: StorageDevice[]; - targetDevices: StorageDevice[]; - isOpen?: boolean; - onCancel: () => void; - onAccept: (volume: Volume) => void; -}; - -/** - * Renders a dialog that allows the user to change the location of a volume. - * @component - */ -export default function VolumeLocationDialog({ - volume, - volumes, - volumeDevices, - targetDevices, - isOpen, - onCancel, - onAccept, - ...props -}: VolumeLocationDialogProps) { - /** @type {StorageDevice|undefined} */ - const initialDevice: StorageDevice | undefined = - volume.targetDevice || targetDevices[0] || volumeDevices[0]; - /** @type {VolumeTarget} */ - const initialTarget: VolumeTarget = sanitizeTarget(volume, initialDevice); - - const [target, setTarget] = useState(initialTarget); - const [targetDevice, setTargetDevice] = useState(initialDevice); - - /** @type {(devices: StorageDevice[]) => void} */ - const changeTargetDevice: (devices: StorageDevice[]) => void = (devices): void => { - const newTargetDevice = devices[0]; - - if (newTargetDevice.name !== targetDevice.name) { - setTarget(defaultTarget(newTargetDevice)); - setTargetDevice(newTargetDevice); - } - }; - - /** @type {(e: import("react").FormEvent) => void} */ - const onSubmit: (e: import("react").FormEvent) => void = (e): void => { - e.preventDefault(); - const newVolume = { ...volume, target, targetDevice }; - onAccept(newVolume); - }; - - /** @type {(device: StorageDevice) => boolean} */ - const isDeviceSelectable: (device: StorageDevice) => boolean = (device): boolean => { - return device.isDrive || ["md", "partition", "lvmLv"].includes(device.type); - }; - - const targets = availableTargets(volume, targetDevice); - - if (!targetDevice) return null; - - // TRANSLATORS: Description of the dialog for changing the location of a file system. - const dialogDescription = _( - "The file systems are allocated at the installation device by \ -default. Indicate a custom location to create the file system at a specific device.", - ); - - return ( - -
- {/** FIXME: Rename FormReadOnlyField */} - -
- d.sid)} - variant="compact" - /> -
- - - setTarget(VolumeTarget.NEW_PARTITION)} - /> - setTarget(VolumeTarget.NEW_VG)} - /> - setTarget(VolumeTarget.DEVICE)} - /> - setTarget(VolumeTarget.FILESYSTEM)} - /> - - - - - - - -
- ); -} diff --git a/web/src/components/storage/VolumeLocationSelectorTable.tsx b/web/src/components/storage/VolumeLocationSelectorTable.tsx deleted file mode 100644 index 78f063901a..0000000000 --- a/web/src/components/storage/VolumeLocationSelectorTable.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 { Chip, Split } from "@patternfly/react-core"; -import { _ } from "~/i18n"; -import { - DeviceName, - DeviceDetails, - DeviceSize, - toStorageDevice, -} from "~/components/storage/device-utils"; -import { ExpandableSelector } from "~/components/core"; -import { - ExpandableSelectorColumn, - ExpandableSelectorProps, -} from "~/components/core/ExpandableSelector"; -import { PartitionSlot, StorageDevice, Volume } from "~/types/storage"; -import { DeviceInfo } from "~/api/storage/types"; - -/** - * Returns what (volumes, installation device) is using a device. - */ -const deviceUsers = ( - item: PartitionSlot | StorageDevice, - targetDevices: StorageDevice[], - volumes: Volume[], -): string[] => { - const device = toStorageDevice(item); - if (!device) return []; - - const isTargetDevice = !!targetDevices.find((d) => d.name === device.name); - const volumeUsers = volumes.filter((v) => v.targetDevice?.name === device.name); - - const users = []; - if (isTargetDevice) users.push(_("Installation device")); - - return users.concat(volumeUsers.map((v) => v.mountPath)); -}; - -/** - * @component - */ -const DeviceUsage = ({ users }: { users: string[] }) => { - return ( - - {users.map((user, index) => ( - - {user} - - ))} - - ); -}; - -type VolumeLocationSelectorTableBaseProps = { - devices: StorageDevice[]; - selectedDevices: StorageDevice[]; - targetDevices: StorageDevice[]; - volumes: Volume[]; -}; - -export type VolumeLocationSelectorTableProps = VolumeLocationSelectorTableBaseProps & - ExpandableSelectorProps; - -/** - * Table for selecting the location for a volume. - * @component - */ -export default function VolumeLocationSelectorTable({ - devices, - selectedDevices, - targetDevices, - volumes, - ...props -}: VolumeLocationSelectorTableProps) { - const columns: ExpandableSelectorColumn[] = [ - { - name: _("Device"), - value: (item: PartitionSlot | StorageDevice) => , - }, - { - name: _("Details"), - value: (item: PartitionSlot | StorageDevice) => , - }, - { - name: _("Usage"), - value: (item: PartitionSlot | StorageDevice) => ( - - ), - }, - { - name: _("Size"), - value: (item: PartitionSlot | StorageDevice) => , - classNames: "sizes-column", - }, - ]; - - return ( - { - if (!device.sid) { - return "dimmed-row"; - } - }} - itemsSelected={selectedDevices} - className="devices-table" - {...props} - /> - ); -} diff --git a/web/src/components/storage/index.ts b/web/src/components/storage/index.ts index 27639b6376..2e1a4044c4 100644 --- a/web/src/components/storage/index.ts +++ b/web/src/components/storage/index.ts @@ -21,7 +21,6 @@ */ export { default as ProposalPage } from "./ProposalPage"; -export { default as ProposalSettingsSection } from "./ProposalSettingsSection"; export { default as ProposalTransactionalInfo } from "./ProposalTransactionalInfo"; export { default as ProposalActionsDialog } from "./ProposalActionsDialog"; export { default as ProposalResultSection } from "./ProposalResultSection"; From 063ed7c670f3ddcfdea0821a5efd7e1eeff48036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 15 Jan 2025 18:30:18 +0000 Subject: [PATCH 2/2] fix(web): drop some core components Which are no longer referenced and probably no longer needed. --- web/src/components/core/Description.jsx | 50 ------------- web/src/components/core/Description.test.jsx | 69 ------------------ web/src/components/core/FormReadOnlyField.jsx | 61 ---------------- .../core/FormReadOnlyField.test.jsx | 34 --------- .../components/core/NumericTextInput.test.tsx | 72 ------------------- web/src/components/core/NumericTextInput.tsx | 55 -------------- web/src/components/core/Tip.jsx | 54 -------------- web/src/components/core/Tip.test.jsx | 68 ------------------ web/src/components/core/index.ts | 4 -- 9 files changed, 467 deletions(-) delete mode 100644 web/src/components/core/Description.jsx delete mode 100644 web/src/components/core/Description.test.jsx delete mode 100644 web/src/components/core/FormReadOnlyField.jsx delete mode 100644 web/src/components/core/FormReadOnlyField.test.jsx delete mode 100644 web/src/components/core/NumericTextInput.test.tsx delete mode 100644 web/src/components/core/NumericTextInput.tsx delete mode 100644 web/src/components/core/Tip.jsx delete mode 100644 web/src/components/core/Tip.test.jsx diff --git a/web/src/components/core/Description.jsx b/web/src/components/core/Description.jsx deleted file mode 100644 index fa6cf2e5b5..0000000000 --- a/web/src/components/core/Description.jsx +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @ts-check - -import React from "react"; -import { Popover, Button } from "@patternfly/react-core"; - -/** - * Displays details popup after clicking the children elements - * @component - * - * @param {object} props - * @param {React.ReactElement} props.description - Content displayed in a popup. - * @param {React.ReactNode} props.children - The wrapped content. - * @param {import("@patternfly/react-core").PopoverProps} [props.otherProps] - */ -export default function Description({ description, children, ...otherProps }) { - if (description) { - return ( - - - - ); - } - - // none or empty description, just return the children - return children; -} diff --git a/web/src/components/core/Description.test.jsx b/web/src/components/core/Description.test.jsx deleted file mode 100644 index 5d4a42a56a..0000000000 --- a/web/src/components/core/Description.test.jsx +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 { Description } from "~/components/core"; - -describe("Description", () => { - const description = "Some great description"; - const item = "Item with description"; - - it("displays the description after clicking the object", async () => { - const { user } = plainRender({item}); - - // the description is not displayed just after the render - expect(screen.queryByText(description)).not.toBeInTheDocument(); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - - // click it - const item_node = screen.getByText(item); - await user.click(item_node); - - // then the description is visible in a dialog - screen.getByRole("dialog"); - screen.getByText(description); - }); - - const expectNoPopup = async (content) => { - const { user } = plainRender(content); - - const item_node = screen.getByText(item); - await user.click(item_node); - - // do not display empty popup - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }; - - it("displays the object without description when it is undefined", async () => { - expectNoPopup({item}); - }); - - it("displays the object without description when it is null", async () => { - expectNoPopup({item}); - }); - - it("displays the object without description when it is empty", async () => { - expectNoPopup({item}); - }); -}); diff --git a/web/src/components/core/FormReadOnlyField.jsx b/web/src/components/core/FormReadOnlyField.jsx deleted file mode 100644 index 3ca98b9d4c..0000000000 --- a/web/src/components/core/FormReadOnlyField.jsx +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 -// cspell:ignore labelable - -import React from "react"; -import styles from "@patternfly/react-styles/css/components/Form/form"; - -/** - * Renders a read-only form value with a label that visually looks identically - * that a a label of an editable form value, without using the `label` HTML tag. - * - * Basically, this "mimicking component" is needed for two reasons: - * - * - The HTML specification limits the use of labels to "labelable elements". - * - * > Some elements, not all of them form-associated, are categorized as labelable - * > elements. These are elements that can be associated with a label element. - * > => button, input (if the type attribute is not in the Hidden state), meter, - * > output, progress, select, textarea, form-associated custom elements - * > - * > https://html.spec.whatwg.org/multipage/forms.html#categories - * - * - Agama does not use disabled form controls for rendering a value that users - * have no chance to change by any means, but a raw text instead. - * - * Based on PatternFly Form styles to maintain consistency. - * - * @typedef {import("react").ComponentProps<"div">} HTMLDivProps - * @param {HTMLDivProps & { label: string }} props - */ -export default function FormReadOnlyField({ label, children, className = "", ...props }) { - return ( -
-
- {label} -
- {children} -
- ); -} diff --git a/web/src/components/core/FormReadOnlyField.test.jsx b/web/src/components/core/FormReadOnlyField.test.jsx deleted file mode 100644 index c32ed904d7..0000000000 --- a/web/src/components/core/FormReadOnlyField.test.jsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 { FormReadOnlyField } from "~/components/core"; - -it("renders label and content wrapped in div nodes using expected PatternFly styles", () => { - plainRender(Agama); - const field = screen.getByText("Agama"); - const label = screen.getByText("Product"); - expect(field.classList.contains("pf-v5-c-form__group")).toBe(true); - expect(label.classList.contains("pf-v5-c-form__label-text")).toBe(true); -}); diff --git a/web/src/components/core/NumericTextInput.test.tsx b/web/src/components/core/NumericTextInput.test.tsx deleted file mode 100644 index 760aac843e..0000000000 --- a/web/src/components/core/NumericTextInput.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (c) [2023-2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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/NumericTextInput.tsx b/web/src/components/core/NumericTextInput.tsx deleted file mode 100644 index 50302af7b2..0000000000 --- a/web/src/components/core/NumericTextInput.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) [2023-2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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, TextInputProps } from "@patternfly/react-core"; -import { noop } from "~/utils"; - -type NumericTextInputProps = { - value: string | number; - onChange: (value: string | number) => void; -} & Omit; - -/** - * Helper component for having an input text limited to not signed numbers - * @component - * - * Based on {@link https://www.patternfly.org/components/forms/text-input PF/TextInput} - * - * @note It allows empty value too. - */ -export default function NumericTextInput({ - value = "", - onChange = noop, - ...textInputProps -}: NumericTextInputProps) { - // NOTE: Using \d* instead of \d+ at the beginning to allow empty - const pattern = /^\d*\.?\d*$/; - - const handleOnChange: TextInputProps["onChange"] = (_, value) => { - if (pattern.test(value)) { - onChange(value); - } - }; - - return ; -} diff --git a/web/src/components/core/Tip.jsx b/web/src/components/core/Tip.jsx deleted file mode 100644 index 1f2ee5976d..0000000000 --- a/web/src/components/core/Tip.jsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @ts-check - -import React from "react"; -import { Label } from "@patternfly/react-core"; - -import { Description } from "~/components/core"; -import { Icon } from "~/components/layout"; - -/** - * Display a label with additional details. The details are displayed after - * clicking the label and the "i" icon indicates available details. - * If the label is not defined or is empty it behaves like a plain label. - * @component - * - * @param {object} props - * @param {React.ReactElement} props.description - Details displayed after clicking the label. - * @param {React.ReactNode} props.children - The content of the label. - */ -export default function Tip({ description, children }) { - if (description) { - return ( - - - - ); - } - - return ; -} diff --git a/web/src/components/core/Tip.test.jsx b/web/src/components/core/Tip.test.jsx deleted file mode 100644 index 54f34bdf51..0000000000 --- a/web/src/components/core/Tip.test.jsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * 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 the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 { Tip } from "~/components/core"; - -describe("Tip", () => { - const description = "Some great description"; - const label = "Label"; - - describe("The description is not empty", () => { - it("displays the label with the 'info' icon and show the description after click", async () => { - const { user, container } = plainRender({label}); - - // an icon is displayed - expect(container.querySelector("svg")).toBeInTheDocument(); - - // the description is not displayed just after the render - expect(screen.queryByText(description)).not.toBeInTheDocument(); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - - // click it - const label_node = screen.getByText(label); - await user.click(label_node); - - // then the description is visible in a dialog - screen.getByRole("dialog"); - screen.getByText(description); - }); - }); - - describe("The description is not defined", () => { - it("displays the label without the 'info' icon and clicking does not show any popup", async () => { - const { user, container } = plainRender({label}); - - // no icon - expect(container.querySelector("svg")).not.toBeInTheDocument(); - - // click it - const label_node = screen.getByText(label); - await user.click(label_node); - - // no popup - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - }); -}); diff --git a/web/src/components/core/index.ts b/web/src/components/core/index.ts index dd18a31925..e77cbce6a3 100644 --- a/web/src/components/core/index.ts +++ b/web/src/components/core/index.ts @@ -21,9 +21,7 @@ */ export { default as ChangeProductLink } from "./ChangeProductLink"; -export { default as Description } from "./Description"; export { default as FormLabel } from "./FormLabel"; -export { default as FormReadOnlyField } from "./FormReadOnlyField"; export { default as FormValidationError } from "./FormValidationError"; export { default as Fieldset } from "./Fieldset"; export { default as Em } from "./Em"; @@ -40,8 +38,6 @@ export { default as PasswordAndConfirmationInput } from "./PasswordAndConfirmati export { default as Popup } from "./Popup"; export { default as ProgressReport } from "./ProgressReport"; export { default as ProgressText } from "./ProgressText"; -export { default as Tip } from "./Tip"; -export { default as NumericTextInput } from "./NumericTextInput"; export { default as PasswordInput } from "./PasswordInput"; export { default as ServerError } from "./ServerError"; export { default as ExpandableSelector } from "./ExpandableSelector";