Skip to content

Commit

Permalink
Merge branch 'storage-config-ui' into storage-add-partition
Browse files Browse the repository at this point in the history
  • Loading branch information
joseivanlopez committed Feb 7, 2025
2 parents 8924cae + dbcd879 commit 83be3b6
Show file tree
Hide file tree
Showing 9 changed files with 331 additions and 34 deletions.
47 changes: 47 additions & 0 deletions web/src/components/core/MenuHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright (c) [2023-2025] 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 { Flex, Content } from "@patternfly/react-core";
import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing";

export type MenuHeaderProps = { title: string; description: React.ReactNode };

/**
* Renders the content to be used at a menu entry describing subsequent options.
* @component
*
* @param title - Short sentence describing the functionality.
* @param descriptions - Extra details rendered with a smaller font.
*/
export default function MenuHeader({ title, description }: MenuHeaderProps) {
return (
<Flex
direction={{ default: "column" }}
gap={{ default: "gapXs" }}
className={[spacingStyles.pxMd, spacingStyles.pyXs].join(" ")}
>
<Content component="h4">{title}</Content>
{description && <small>{description}</small>}
</Flex>
);
}
1 change: 1 addition & 0 deletions web/src/components/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,4 @@ export { default as Drawer } from "./Drawer";
export { default as SelectWrapper } from "./SelectWrapper";
export { default as NestedContent } from "./NestedContent";
export { default as SubtleContent } from "./SubtleContent";
export { default as MenuHeader } from "./MenuHeader";
111 changes: 111 additions & 0 deletions web/src/components/storage/AddExistingDeviceMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* Copyright (c) [2025] 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 AddExistingDeviceMenu from "~/components/storage/AddExistingDeviceMenu";
import { StorageDevice } from "~/types/storage";
import * as ConfigModel from "~/api/storage/types/config-model";

const vda: StorageDevice = {
sid: 59,
type: "disk",
isDrive: true,
description: "",
vendor: "Micron",
model: "Micron 1100 SATA",
driver: ["ahci", "mmcblk"],
bus: "IDE",
name: "/dev/vda",
size: 1e12,
systems: ["Windows 11", "openSUSE Leap 15.2"],
};

const vdb: StorageDevice = {
sid: 60,
type: "disk",
isDrive: true,
description: "",
vendor: "Seagate",
model: "Unknown",
driver: ["ahci", "mmcblk"],
bus: "IDE",
name: "/dev/vdb",
size: 1e6,
systems: [],
};

const vdaDrive: ConfigModel.Drive = {
name: "/dev/vda",
spacePolicy: "delete",
partitions: [],
};

const vdbDrive: ConfigModel.Drive = {
name: "/dev/vdb",
spacePolicy: "delete",
partitions: [],
};

const mockUseConfigModelFn = jest.fn();
const mockAddDriveFn = jest.fn();

jest.mock("~/queries/storage", () => ({
...jest.requireActual("~/queries/storage"),
useConfigModel: () => mockUseConfigModelFn(),
useAvailableDevices: () => [vda, vdb],
useModel: () => ({ addDrive: mockAddDriveFn }),
}));

describe("when there are unused disks", () => {
beforeEach(() => {
mockUseConfigModelFn.mockReturnValue({ drives: [] });
});

it("renders the menu", async () => {
plainRender(<AddExistingDeviceMenu />);
expect(screen.queryByText(/Use additional disk/)).toBeInTheDocument();
});

it("allows users to add a new drive", async () => {
const { user } = plainRender(<AddExistingDeviceMenu />);

const button = screen.getByRole("button", { name: /Use additional/ });
await user.click(button);
const devicesMenu = screen.getByRole("menu");
const vdaItem = within(devicesMenu).getByRole("menuitem", { name: /vda/ });
await user.click(vdaItem);
expect(mockAddDriveFn).toHaveBeenCalled();
});
});

describe("when there are no more unused disks", () => {
beforeEach(() => {
mockUseConfigModelFn.mockReturnValue({ drives: [vdaDrive, vdbDrive] });
});

it("renders nothing", async () => {
plainRender(<AddExistingDeviceMenu />);
expect(screen.queryByText(/Use additional disk/)).toBeNull();
});
});
100 changes: 100 additions & 0 deletions web/src/components/storage/AddExistingDeviceMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Copyright (c) [2025] 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 { _, n_ } from "~/i18n";
import { sprintf } from "sprintf-js";
import {
Dropdown,
DropdownList,
DropdownItem,
DropdownGroup,
MenuToggleElement,
MenuToggle,
Divider,
} from "@patternfly/react-core";
import { MenuHeader } from "~/components/core";
import MenuDeviceDescription from "./MenuDeviceDescription";
import { useAvailableDevices } from "~/queries/storage";
import { useConfigModel, useModel } from "~/queries/storage/config-model";
import { deviceLabel } from "~/components/storage/utils";

export default function AddExistingDeviceMenu() {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(!isOpen);
const allDevices = useAvailableDevices();
const model = useConfigModel({ suspense: true });
const modelHook = useModel();

const drivesNames = model.drives.map((d) => d.name);
const devices = allDevices.filter((d) => !drivesNames.includes(d.name));

const Header = ({ drives }) => {
const desc = sprintf(
n_(
"Extends the installation beyond the currently selected disk",
"Extends the installation beyond the current %d disks",
drives.length,
),
drives.length,
);

return <MenuHeader title={_("Select another disk to define partitions")} description={desc} />;
};

if (!devices.length) return null;

return (
<Dropdown
isOpen={isOpen}
onOpenChange={toggle}
onSelect={toggle}
onActionClick={toggle}
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
onClick={toggle}
aria-label={_("Use additional disk toggle")}
isExpanded={isOpen}
>
{_("Use additional disk")}
</MenuToggle>
)}
>
<DropdownList>
{/* @ts-expect-error See https://github.com/patternfly/patternfly/issues/7327 */}
<DropdownGroup label={<Header drives={model.drives} />}>
<Divider />
{devices.map((device) => (
<DropdownItem
key={device.sid}
description={<MenuDeviceDescription device={device} />}
onClick={() => modelHook.addDrive(device.name)}
>
{deviceLabel(device)}
</DropdownItem>
))}
</DropdownGroup>
</DropdownList>
</Dropdown>
);
}
1 change: 0 additions & 1 deletion web/src/components/storage/ConfigEditorMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ export default function ConfigEditorMenu() {
)}
>
<DropdownList>
<DropdownItem key="disk">{_("Use additional disk")}</DropdownItem>
<DropdownItem key="vg">{_("Add LVM volume group")}</DropdownItem>
<DropdownItem key="raid">{_("Add MD RAID")}</DropdownItem>
<Divider />
Expand Down
36 changes: 3 additions & 33 deletions web/src/components/storage/DriveEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import * as driveUtils from "~/components/storage/utils/drive";
import * as partitionUtils from "~/components/storage/utils/partition";
import { typeDescription, contentDescription } from "~/components/storage/utils/device";
import { Icon } from "../layout";
import { MenuHeader } from "~/components/core";
import MenuDeviceDescription from "./MenuDeviceDescription";
import {
Card,
CardBody,
Expand All @@ -43,7 +45,6 @@ import {
Flex,
Label,
Split,
Stack,
Menu,
MenuContainer,
MenuContent,
Expand All @@ -54,7 +55,6 @@ import {
MenuToggleProps,
MenuToggleElement,
MenuGroup,
Content,
} from "@patternfly/react-core";

import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing";
Expand All @@ -73,17 +73,6 @@ export const InlineMenuToggle = React.forwardRef(
),
);

const MenuHeader = ({ title, description }) => (
<Flex
direction={{ default: "column" }}
gap={{ default: "gapXs" }}
className={[spacingStyles.pxMd, spacingStyles.pyXs].join(" ")}
>
<Content component="h4">{title}</Content>
{description && <small>{description}</small>}
</Flex>
);

// FIXME: Presentation is quite poor
const SpacePolicySelectorIntro = ({ device }) => {
const main = _("Choose what to with current content");
Expand Down Expand Up @@ -288,25 +277,6 @@ const SearchSelectorMultipleOptions = ({ selected, withNewVg = false, onChange }
const navigate = useNavigate();
const devices = useAvailableDevices();

// FIXME: Presentation is quite poor
const DeviceDescription = ({ device }) => {
return (
<Stack>
<Split hasGutter>
<span>{typeDescription(device)}</span>
<span>{contentDescription(device)}</span>
</Split>
<Split hasGutter>
{device.systems.map((s, i) => (
<Label key={i} isCompact>
{s}
</Label>
))}
</Split>
</Stack>
);
};

const NewVgOption = () => {
if (withNewVg)
return (
Expand Down Expand Up @@ -335,7 +305,7 @@ const SearchSelectorMultipleOptions = ({ selected, withNewVg = false, onChange }
key={device.sid}
itemId={device.sid}
isSelected={isSelected}
description={<DeviceDescription device={device} />}
description={<MenuDeviceDescription device={device} />}
onClick={() => onChange(device.name)}
>
<Name />
Expand Down
Loading

0 comments on commit 83be3b6

Please sign in to comment.