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)}
/>