diff --git a/web/src/components/storage/ConfigureDeviceMenu.tsx b/web/src/components/storage/ConfigureDeviceMenu.tsx index 3e4b68867c..edec2da052 100644 --- a/web/src/components/storage/ConfigureDeviceMenu.tsx +++ b/web/src/components/storage/ConfigureDeviceMenu.tsx @@ -209,9 +209,11 @@ export default function ConfigureDeviceMenu(): React.ReactNode { usedCount={usedDevicesCount} /> } - disksIntro={_("Choose a disk to define partitions or to mount")} - mdRaidsIntro={_("Choose a RAID device to define partitions or to mount")} - volumeGroupsIntro={_("Choose a volume group to define logical volumes")} + tabIntros={{ + disks: _("Choose a disk to define partitions or to mount"), + mdRaids: _("Choose a RAID device to define partitions or to mount"), + volumeGroups: _("Choose a volume group to define logical volumes"), + }} onCancel={closeDeviceSelector} onConfirm={([device]) => { addDevice(device); diff --git a/web/src/components/storage/DeviceSelectorModal.test.tsx b/web/src/components/storage/DeviceSelectorModal.test.tsx index 68651e0ad7..5bbeda3cd4 100644 --- a/web/src/components/storage/DeviceSelectorModal.test.tsx +++ b/web/src/components/storage/DeviceSelectorModal.test.tsx @@ -208,7 +208,7 @@ describe("DeviceSelectorModal", () => { Disk selection note

} + sideEffects={{ disks:

Disk selection note

}} title="Select" onCancel={onCancelMock} onConfirm={onConfirmMock} @@ -224,7 +224,7 @@ describe("DeviceSelectorModal", () => { RAID selection note

} + sideEffects={{ mdRaids:

RAID selection note

}} title="Select" onCancel={onCancelMock} onConfirm={onConfirmMock} @@ -241,7 +241,7 @@ describe("DeviceSelectorModal", () => { LVM selection note

} + sideEffects={{ volumeGroups:

LVM selection note

}} title="Select" onCancel={onCancelMock} onConfirm={onConfirmMock} @@ -258,7 +258,7 @@ describe("DeviceSelectorModal", () => { Disk selection note

} + sideEffects={{ disks:

Disk selection note

}} title="Select" onCancel={onCancelMock} onConfirm={onConfirmMock} @@ -295,7 +295,7 @@ describe("DeviceSelectorModal", () => { it("shows the create link in the empty LVM state when newVolumeGroupLinkText is given", async () => { const { user } = installerRender( { const { user } = installerRender( { }); }); + describe("tabIntros", () => { + it("shows intro text in the Disks tab when devices are present", () => { + installerRender( + Disk intro text

}} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + screen.getByText("Disk intro text"); + }); + + it("shows intro text in the RAID tab when devices are present", async () => { + const { user } = installerRender( + RAID intro text

}} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + screen.getByText("RAID intro text"); + }); + + it("shows intro text in the LVM tab when devices are present", async () => { + const { user } = installerRender( + LVM intro text

}} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + screen.getByText("LVM intro text"); + }); + + it("does not show intro text when tab is empty", async () => { + const { user } = installerRender( + RAID intro text

}} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + expect(screen.queryByText("RAID intro text")).toBeNull(); + }); + }); + + describe("custom empty states", () => { + it("shows custom empty state title for Disks tab", () => { + installerRender( + , + ); + screen.getByText("Custom disk title"); + }); + + it("shows custom empty state body for RAID tab", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + screen.getByText("Custom RAID body text"); + }); + + it("shows custom empty state for LVM tab", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + screen.getByText("No VGs available"); + screen.getByText("Cannot format volume groups"); + }); + + it("falls back to default empty state when custom not provided", () => { + installerRender( + , + ); + screen.getByText("No disks found"); + screen.getByText("No disks are available for selection."); + }); + }); + + describe("newDeviceLinkTexts", () => { + // RAID device creation is not yet implemented (no STORAGE.mdRaid.add route exists) + it.skip("shows create link for RAID in empty state", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + screen.getByRole("link", { name: "Create new RAID" }); + }); + + it("shows create link for LVM in empty state", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + screen.getByRole("link", { name: "Create new LVM" }); + }); + + // RAID device creation is not yet implemented (no STORAGE.mdRaid.add route exists) + it.skip("shows create link for RAID when devices exist", async () => { + const { user } = installerRender( + , + ); + await user.click(screen.getByRole("tab", { name: "RAID" })); + screen.getByRole("link", { name: "Add another RAID" }); + }); + + it("shows both tabIntros and newDeviceLinkTexts together", async () => { + const { user } = installerRender( + Choose a volume group

}} + newDeviceLinkTexts={{ volumeGroups: "Create new VG" }} + title="Select" + onCancel={onCancelMock} + onConfirm={onConfirmMock} + />, + ); + await user.click(screen.getByRole("tab", { name: "LVM" })); + screen.getByText("Choose a volume group"); + screen.getByRole("link", { name: "Create new VG" }); + }); + }); + describe("actions", () => { it("triggers onCancel when user selects Cancel", async () => { const { user } = installerRender( diff --git a/web/src/components/storage/DeviceSelectorModal.tsx b/web/src/components/storage/DeviceSelectorModal.tsx index 188ed34c28..3697465bf4 100644 --- a/web/src/components/storage/DeviceSelectorModal.tsx +++ b/web/src/components/storage/DeviceSelectorModal.tsx @@ -55,6 +55,37 @@ import type { Storage } from "~/model/system"; /** Identifies which tab is active in {@link DeviceSelectorModal}. */ export type TabKey = "disks" | "mdRaids" | "volumeGroups"; +/** Tab keys for device types that can be created (excludes disks). */ +export type CreatableTabKey = "mdRaids" | "volumeGroups"; + +/** Side effects shown when selecting a device from a specific tab. */ +export type SideEffects = { + [K in TabKey]?: React.ReactNode; +}; + +/** Introductory content shown at the top of each tab. */ +export type TabIntros = { + [K in TabKey]?: React.ReactNode; +}; + +/** Empty state titles for each tab when no devices are available. */ +export type EmptyStateTitles = { + [K in TabKey]?: React.ReactNode; +}; + +/** Empty state body text for each tab when no devices are available. */ +export type EmptyStateBodies = { + [K in TabKey]?: React.ReactNode; +}; + +/** + * Link text for creating new devices. + * Only available for device types that can be created (excludes disks). + */ +export type NewDeviceLinkTexts = { + [K in CreatableTabKey]?: React.ReactNode; +}; + /** Props for {@link DeviceSelectorModal}. */ export type DeviceSelectorModalProps = Omit & { /** General information shown at the top of the modal, above the tabs. */ @@ -69,25 +100,25 @@ export type DeviceSelectorModalProps = Omit ( @@ -122,16 +153,15 @@ const NoDevicesFound = ({ ); /** - * Subtle contextual sentence with an inline link embedded via bracket notation. - * The link position and text are extracted from `sentence` using `[text]` - * markers. + * Renders a link to create a new device, styled as subtle content. + * The link position and text are extracted from `text` using `[text]` markers. */ -const TabIntro = ({ sentence, linkTo }: { sentence: string; linkTo?: string }) => { - const [before, linkText, after] = sentence.split(/[[\]]/); +const CreateDeviceLink = ({ text, to }: { text: string; to: string }) => { + const [before, linkText, after] = text.split(/[[\]]/); return ( {before} - + {linkText} {after} @@ -150,8 +180,8 @@ const TabContent = ({ intro, children, }: { - emptyTitle: string; - emptyBody: string; + emptyTitle: React.ReactNode; + emptyBody: React.ReactNode; emptyAction?: React.ReactNode; intro?: React.ReactNode; children?: React.ReactNode; @@ -220,14 +250,11 @@ export default function DeviceSelectorModal({ disks = [], mdRaids = [], volumeGroups = [], - disksSideEffects, - mdRaidsSideEffects, - volumeGroupsSideEffects, - disksIntro, - mdRaidsIntro, - volumeGroupsIntro, - volumeGroupsEmptyTitle, - newVolumeGroupLinkText, + sideEffects, + tabIntros, + emptyStateTitles, + emptyStateBodies, + newDeviceLinkTexts, autoSelectOnTabChange = true, ...popupProps }: DeviceSelectorModalProps): React.ReactNode { @@ -245,9 +272,9 @@ export default function DeviceSelectorModal({ const deviceSideEffectsAlert = currentDevice && [ - { list: disks, alert: disksSideEffects }, - { list: mdRaids, alert: mdRaidsSideEffects }, - { list: volumeGroups, alert: volumeGroupsSideEffects }, + { list: disks, alert: sideEffects?.disks }, + { list: mdRaids, alert: sideEffects?.mdRaids }, + { list: volumeGroups, alert: sideEffects?.volumeGroups }, ].find(({ list }) => list.some((d) => d.sid === currentDevice.sid))?.alert; const deviceInInitialTab = @@ -297,9 +324,9 @@ export default function DeviceSelectorModal({ > {disks.length > 0 && ( {mdRaids.length > 0 && ( {newVolumeGroupLinkText} + newDeviceLinkTexts?.volumeGroups && ( + {newDeviceLinkTexts.volumeGroups} ) } > {volumeGroups.length > 0 && ( <> - {newVolumeGroupLinkText && ( - )} setIsSelectorOpen(false)} /> diff --git a/web/src/components/storage/SearchedVolumeGroupMenu.tsx b/web/src/components/storage/SearchedVolumeGroupMenu.tsx index ab95b3cc54..93ddc5f030 100644 --- a/web/src/components/storage/SearchedVolumeGroupMenu.tsx +++ b/web/src/components/storage/SearchedVolumeGroupMenu.tsx @@ -271,8 +271,10 @@ export default function SearchedVolumeGroupMenu({ intro={} device={device} deviceConfig={deviceConfig} - disksSideEffects={} - mdRaidsSideEffects={} + sideEffects={{ + disks: , + mdRaids: , + }} onConfirm={onDeviceChange} onCancel={() => setIsSelectorOpen(false)} />