refactor(board): modernize item and application select modals with SelectableCard (#6642)

Modernize board item, application, and integration selection flows with shared Mantine components, batch board actions, improved accessibility, and hardened persistence.
This commit is contained in:
Thomas authored and GitHub committed 2026-08-21 03:19:18 +02:00
1 parent bd4f6a92c6
commit f3014fe271
60 files changed
+3791 -1839

No files matched your search

@@ -11,6 +11,19 @@
border-color: var(--border-color) !important;
}
.settingsButton {
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
:global([data-type="item"]:hover) .settingsButton,
:global([data-type="item"]:focus-within) .settingsButton,
.settingsButton[data-menu-open="true"] {
opacity: 1;
pointer-events: auto;
}
.editModeInertContent {
display: contents;
}
@@ -39,3 +52,9 @@
opacity: 1;
pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
.settingsButton {
transition: none;
}
}
@@ -14,6 +14,7 @@ import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-ite
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { useOpenItemMoveModal } from "./item-move-modal";
import itemContentClasses from "./item-content.module.css";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface BoardItemMenuProps {
@@ -105,9 +106,11 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
size={24}
radius="sm"
pos="absolute"
top={12}
right={8}
top={4}
right={4}
style={{ zIndex: 30 }}
className={itemContentClasses.settingsButton}
data-menu-open={isMenuOpen || undefined}
data-board-widget-settings
aria-label={tItem("menu.label.settingsFor", { name: label })}
>
@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { IntegrationKind } from "@homarr/definitions";
import {
getWidgetConnectionStatus,
resolveMatchingIntegrationsAsync,
@@ -8,95 +10,254 @@ import {
} from "./item-select-data";
describe("getWidgetConnectionStatus", () => {
const availableKinds = new Set(["sonarr" as const]);
const availableKinds = new Set<IntegrationKind>(["sonarr", "plex"]);
it("reports widgets with a configured compatible integration as ready", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr", "radarr"],
availableKinds,
connectionOptional: false,
}),
).toBe("ready");
describe("ready status", () => {
it("returns 'ready' when at least one supported integration is in availableKinds", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr", "radarr"],
availableKinds,
connectionOptional: false,
}),
).toBe("ready");
});
it("returns 'ready' when all supported integrations are in availableKinds", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr", "plex"],
availableKinds,
connectionOptional: false,
}),
).toBe("ready");
});
it("returns 'ready' even if connectionOptional is true as long as a matching integration exists", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr"],
availableKinds,
connectionOptional: true,
}),
).toBe("ready");
});
});
it("reports required integrations that are not configured as needing setup", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["radarr"],
availableKinds,
connectionOptional: false,
}),
).toBe("needsSetup");
describe("needsSetup status", () => {
it("returns 'needsSetup' when supported integrations are non-empty, none are available, and connectionOptional is false", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["radarr", "sabNzbd"],
availableKinds,
connectionOptional: false,
}),
).toBe("needsSetup");
});
it("returns 'needsSetup' when availableKinds is empty and connection is required", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr"],
availableKinds: new Set<IntegrationKind>(),
connectionOptional: false,
}),
).toBe("needsSetup");
});
});
it.each([
{ supportedIntegrations: [], connectionOptional: false },
{ supportedIntegrations: ["radarr" as const], connectionOptional: true },
])("reports a widget without a required connection", ({ supportedIntegrations, connectionOptional }) => {
expect(getWidgetConnectionStatus({ supportedIntegrations, availableKinds, connectionOptional })).toBe(
"noConnectionRequired",
);
describe("noConnectionRequired status", () => {
it("returns 'noConnectionRequired' when supportedIntegrations is empty regardless of connectionOptional", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: [],
availableKinds,
connectionOptional: false,
}),
).toBe("noConnectionRequired");
expect(
getWidgetConnectionStatus({
supportedIntegrations: [],
availableKinds: new Set(),
connectionOptional: true,
}),
).toBe("noConnectionRequired");
});
it("returns 'noConnectionRequired' when connectionOptional is true and no supported integrations are available", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["radarr", "jellyfin"],
availableKinds,
connectionOptional: true,
}),
).toBe("noConnectionRequired");
});
it("returns 'noConnectionRequired' when connectionOptional is true and availableKinds is empty", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr"],
availableKinds: new Set(),
connectionOptional: true,
}),
).toBe("noConnectionRequired");
});
});
});
describe("widget selection lock", () => {
it("rejects repeated selection synchronously until the active selection finishes", () => {
describe("selection concurrency locks", () => {
it("acquires lock on unlocked ref and sets current to true", () => {
const lock = { current: false };
const acquired = tryLockSelection(lock);
expect(acquired).toBe(true);
expect(lock.current).toBe(true);
});
it("rejects lock acquisition on an already locked ref", () => {
const lock = { current: true };
const acquired = tryLockSelection(lock);
expect(acquired).toBe(false);
expect(lock.current).toBe(true);
});
it("unlocks lock and allows subsequent acquisition", () => {
const lock = { current: false };
expect(tryLockSelection(lock)).toBe(true);
expect(tryLockSelection(lock)).toBe(false);
unlockSelection(lock);
expect(lock.current).toBe(false);
expect(tryLockSelection(lock)).toBe(true);
});
it("handles repeated unlock safely without throwing", () => {
const lock = { current: false };
expect(() => {
unlockSelection(lock);
unlockSelection(lock);
}).not.toThrow();
expect(lock.current).toBe(false);
});
it("prevents multiple concurrent callers from proceeding simultaneously", () => {
const lock = { current: false };
const results: boolean[] = [];
// Simulate 5 simultaneous selection attempts
for (let i = 0; i < 5; i++) {
results.push(tryLockSelection(lock));
}
expect(results).toEqual([true, false, false, false, false]);
});
});
describe("resolveMatchingIntegrationsAsync", () => {
it("waits for the shared integration query before a cold integration-backed selection", async () => {
const ensureDataAsync = vi.fn(async () => [
{ id: "matching", kind: "sonarr" as const },
{ id: "other", kind: "radarr" as const },
]);
it("returns empty array immediately when widget does not support integrations without calling ensureDataAsync", async () => {
const ensureDataAsync = vi.fn(async () => [{ id: "1", kind: "sonarr" as IntegrationKind }]);
await expect(
resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr"],
currentData: undefined,
ensureDataAsync,
}),
).resolves.toEqual([{ id: "matching", kind: "sonarr" }]);
expect(ensureDataAsync).toHaveBeenCalledOnce();
});
const result = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport: false,
supportedIntegrations: ["sonarr"],
currentData: undefined,
ensureDataAsync,
});
it("does not wait for integrations when the widget has no integration support", async () => {
const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]);
await expect(
resolveMatchingIntegrationsAsync({
hasIntegrationSupport: false,
supportedIntegrations: [],
currentData: undefined,
ensureDataAsync,
}),
).resolves.toEqual([]);
expect(result).toEqual([]);
expect(ensureDataAsync).not.toHaveBeenCalled();
});
it("excludes matching integrations the user cannot use", async () => {
it("uses currentData synchronously when available without calling ensureDataAsync", async () => {
const currentData = [
{ id: "allowed", kind: "sonarr" as const, permissions: { hasUseAccess: true } },
{ id: "blocked", kind: "sonarr" as const, permissions: { hasUseAccess: false } },
{ id: "1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: true } },
{ id: "2", kind: "radarr" as IntegrationKind, permissions: { hasUseAccess: true } },
{ id: "3", kind: "plex" as IntegrationKind, permissions: { hasUseAccess: true } },
];
const ensureDataAsync = vi.fn();
const result = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr", "radarr"],
currentData,
ensureDataAsync,
});
expect(result).toEqual([currentData[0], currentData[1]]);
expect(ensureDataAsync).not.toHaveBeenCalled();
});
it("calls ensureDataAsync when currentData is undefined and filters by supported kinds", async () => {
const fetchedData = [
{ id: "1", kind: "sonarr" as IntegrationKind },
{ id: "2", kind: "radarr" as IntegrationKind },
{ id: "3", kind: "sabNzbd" as IntegrationKind },
];
const ensureDataAsync = vi.fn(async () => fetchedData);
const result = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["radarr", "sabNzbd"],
currentData: undefined,
ensureDataAsync,
});
expect(result).toEqual([fetchedData[1], fetchedData[2]]);
expect(ensureDataAsync).toHaveBeenCalledOnce();
});
it("excludes integrations where permissions.hasUseAccess is explicitly false", async () => {
const currentData = [
{ id: "allowed-1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: true } },
{ id: "blocked-1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: false } },
{ id: "allowed-no-perms", kind: "sonarr" as IntegrationKind },
];
const result = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr"],
currentData,
ensureDataAsync: vi.fn(),
});
expect(result).toEqual([currentData[0], currentData[2]]);
});
it("returns empty array when none of the fetched integrations match supported kinds", async () => {
const fetchedData = [
{ id: "1", kind: "plex" as IntegrationKind },
{ id: "2", kind: "jellyfin" as IntegrationKind },
];
const ensureDataAsync = vi.fn(async () => fetchedData);
const result = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr", "radarr"],
currentData: undefined,
ensureDataAsync,
});
expect(result).toEqual([]);
expect(ensureDataAsync).toHaveBeenCalledOnce();
});
it("propagates error when ensureDataAsync rejects", async () => {
const ensureDataAsync = vi.fn(async () => {
throw new Error("Network timeout");
});
await expect(
resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr"],
currentData,
ensureDataAsync: vi.fn(),
currentData: undefined,
ensureDataAsync,
}),
).resolves.toEqual([currentData[0]]);
).rejects.toThrow("Network timeout");
});
});
@@ -1,20 +0,0 @@
.action {
opacity: 0;
transition: opacity 150ms ease;
}
.card:hover .action,
.card:focus-within .action {
opacity: 1;
}
@media (hover: none), (pointer: coarse) {
.action {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.action {
transition: none;
}
}
@@ -0,0 +1,211 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { ConnectionStatusBadge, SupportedIntegrations, WidgetItem } from "./item-select-modal";
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => {
const translations: Record<string, string> = {
"item.create.connectionStatus.ready": "Ready",
"item.create.connectionStatus.needsSetup": "Needs setup",
"item.create.connectionStatus.noConnectionRequired": "No connection required",
"item.create.standalone": "Standalone",
"widget.mediaServer.name": "Media Server",
"widget.mediaServer.description": "Stream your media",
"widget.clock.name": "Clock",
"widget.clock.description": "Displays current time",
};
return translations[key] ?? key;
},
useScopedI18n: () => (key: string) => key,
}));
vi.mock("@homarr/definitions", async () => {
const actual = await vi.importActual<Record<string, unknown>>("@homarr/definitions");
return {
...actual,
getIntegrationName: (kind: string) => kind.charAt(0).toUpperCase() + kind.slice(1),
};
});
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
describe("Advanced Add App - Modal Subcomponents", () => {
describe("ConnectionStatusBadge", () => {
it("renders 'Ready' badge with teal color when status is 'ready'", async () => {
await act(async () =>
root.render(
<MantineProvider>
<ConnectionStatusBadge status="ready" />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Ready");
const badge = host.querySelector("span, div");
expect(badge).not.toBeNull();
});
it("renders 'Needs setup' badge with orange color when status is 'needsSetup'", async () => {
await act(async () =>
root.render(
<MantineProvider>
<ConnectionStatusBadge status="needsSetup" />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Needs setup");
});
it("renders null when status is 'noConnectionRequired'", async () => {
await act(async () =>
root.render(
<MantineProvider>
<ConnectionStatusBadge status="noConnectionRequired" />
</MantineProvider>,
),
);
expect(host.querySelector("[class*='Badge-root']")).toBeNull();
});
});
describe("SupportedIntegrations", () => {
it("renders up to 5 avatars for supported integrations", async () => {
const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex"];
await act(async () =>
root.render(
<MantineProvider>
<SupportedIntegrations integrations={integrations} />
</MantineProvider>,
),
);
// Avatars should be rendered for the 3 items
expect(host.querySelectorAll("img, svg, div").length).toBeGreaterThan(0);
expect(host.textContent).not.toContain("+");
});
it("renders overflow indicator when 6 or more integrations are supported", async () => {
const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex", "jellyfin", "emby", "deluge"];
await act(async () =>
root.render(
<MantineProvider>
<SupportedIntegrations integrations={integrations} />
</MantineProvider>,
),
);
// 6 integrations total: 4 shown, +2 in badge
expect(host.textContent).toContain("+2");
});
it("renders Standalone label when integrations list is empty", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SupportedIntegrations integrations={[]} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Standalone");
});
});
describe("WidgetItem", () => {
const FakeIcon = () => <span data-testid="widget-icon">Icon</span>;
it("renders widget card with icon, title, description, and connection status", async () => {
const onSelect = vi.fn();
const item = {
kind: "mediaServer" as WidgetKind,
name: "Media Server",
description: "Stream your media collection effortlessly",
icon: FakeIcon as never,
supportedIntegrations: ["plex" as IntegrationKind, "jellyfin" as IntegrationKind],
};
await act(async () =>
root.render(
<MantineProvider>
<WidgetItem item={item} disabled={false} loading={false} connectionStatus="ready" onSelect={onSelect} />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button).not.toBeNull();
expect(host.textContent).toContain("Media Server");
expect(host.textContent).toContain("Stream your media collection effortlessly");
expect(host.textContent).toContain("Ready");
expect(host.querySelector("[data-testid='widget-icon']")).not.toBeNull();
await act(async () => {
button?.click();
});
expect(onSelect).toHaveBeenCalledOnce();
});
it("does not mark widget cards as selected", async () => {
const item = {
kind: "clock" as WidgetKind,
name: "Clock",
description: "Displays current time",
icon: FakeIcon as never,
supportedIntegrations: [] as IntegrationKind[],
};
await act(async () =>
root.render(
<MantineProvider>
<WidgetItem
item={item}
disabled={false}
loading={false}
connectionStatus="noConnectionRequired"
onSelect={vi.fn()}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.hasAttribute("data-selected")).toBe(false);
});
});
});
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import {
Avatar,
Badge,
@@ -6,16 +6,18 @@ import {
Button,
Center,
Divider,
Flex,
Group,
Image,
Input,
Select,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
import { IconApi, IconBuildingStore, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -23,8 +25,8 @@ import { useSession } from "@homarr/auth/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import {
getIconUrl,
getBoardLaneColumnCount,
getIconUrl,
getIntegrationName,
getRootSectionLane,
widgetIntegrationSupport,
@@ -36,14 +38,14 @@ import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import { SelectableCard } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { useItemActions } from "./item-actions";
import type { WidgetConnectionStatus } from "./item-select-data";
import {
@@ -64,6 +66,16 @@ interface ItemSelectModalContentProps {
initialSearch?: string;
}
export interface WidgetItemData {
kind: WidgetKind;
supportedIntegrations: IntegrationKind[];
icon: TablerIcon;
name: string;
description: string;
}
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
const ItemSelectModalContent = ({
actions,
integrationData,
@@ -108,15 +120,14 @@ const ItemSelectModalContent = ({
[board.sections, currentLayout, mainCanvasSection, t],
);
const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null);
const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId)
? targetSectionId
: (placementOptions[0]?.value ?? null);
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
const selectionLock = useRef(false);
const { createItem, removeItem } = useItemActions();
useEffect(() => {
if (placementOptions.some(({ value }) => value === targetSectionId)) return;
setTargetSectionId(placementOptions[0]?.value ?? null);
}, [placementOptions, targetSectionId]);
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const settings = useSettings();
@@ -128,7 +139,7 @@ const ItemSelectModalContent = ({
[integrationData],
);
const items = useMemo(
const items = useMemo<WidgetItemData[]>(
() =>
widgetKinds
.filter((kind) => kind !== "customApi")
@@ -188,7 +199,7 @@ const ItemSelectModalContent = ({
createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ??
createdItem.layouts[0]?.sectionId;
const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label;
const wasRelocated = targetSectionId !== null && actualSectionId !== targetSectionId && actualDestination;
const wasRelocated = effectiveSectionId !== null && actualSectionId !== effectiveSectionId && actualDestination;
const notificationId = `board-item-created:${itemId}`;
showSuccessNotification({
id: notificationId,
@@ -247,7 +258,7 @@ const ItemSelectModalContent = ({
kind: "customApi",
integrationIds: [],
options: configuredOptions,
targetSectionId: targetSectionId ?? undefined,
targetSectionId: effectiveSectionId ?? undefined,
advancedOptions,
});
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
@@ -310,7 +321,7 @@ const ItemSelectModalContent = ({
kind,
options,
integrationIds: newIntegrationIds,
targetSectionId: targetSectionId ?? undefined,
targetSectionId: effectiveSectionId ?? undefined,
advancedOptions,
});
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
@@ -377,125 +388,233 @@ const ItemSelectModalContent = ({
};
return (
<Flex gap="md" align="stretch">
<Box style={{ flex: "1 1 0%", minWidth: 0 }}>
<SelectGridLayout
search={search}
onSearchChange={setSearch}
placeholder={`${t("item.create.search")}...`}
ariaLabel={t("item.create.search")}
onSearchKeyDown={(event) => {
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
const [item] = filteredItems;
if (item) void handleAdd(item.kind);
}
}}
>
{placementOptions.length > 1 && (
<Box style={{ gridColumn: "1 / -1" }}>
<Select
label={t("item.create.destination.label")}
description={t("item.create.destination.description")}
data={placementOptions}
value={targetSectionId}
onChange={setTargetSectionId}
allowDeselect={false}
/>
</Box>
)}
{filteredItems.map((item) => (
<WidgetItem
key={item.kind}
item={item}
onSelect={() => void handleAdd(item.kind)}
onFocus={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}}
onPointerEnter={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}}
disabled={loadingSelection !== null}
loading={loadingSelection === item.kind}
connectionStatus={getWidgetConnectionStatus({
supportedIntegrations: item.supportedIntegrations,
availableKinds,
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
})}
<Stack gap="md">
{/* Top Search & Destination Selector */}
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
<Box style={{ flex: "1 1 240px", minWidth: 200 }}>
<Input
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
placeholder={`${t("item.create.search")}...`}
aria-label={t("item.create.search")}
data-autofocus
onKeyDown={(event) => {
if (
event.key === "Enter" &&
loadingSelection === null &&
filteredItems.length === 1 &&
filteredItems[0]
) {
void handleAdd(filteredItems[0].kind);
}
}}
/>
</Box>
{placementOptions.length > 1 && (
<Box style={{ flex: "0 1 280px", minWidth: 200 }}>
<Select
label={t("item.create.destination.label")}
data={placementOptions}
value={effectiveSectionId}
onChange={setTargetSectionId}
allowDeselect={false}
size="xs"
/>
))}
</Box>
)}
</Group>
{isAdmin && (
<>
<Divider
label={t("customWidget.page.list.title")}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
{/* Grid of Widgets */}
<SimpleGrid cols={selectGridCols} spacing="sm">
{filteredItems.map((item) => (
<WidgetItem
key={item.kind}
item={item}
onSelect={() => void handleAdd(item.kind)}
onFocus={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}}
onPointerEnter={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}}
disabled={loadingSelection !== null}
loading={loadingSelection === item.kind}
connectionStatus={getWidgetConnectionStatus({
supportedIntegrations: item.supportedIntegrations,
availableKinds,
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
})}
/>
))}
{isAdmin && (
<>
<Divider
label={
<Group gap="xs">
<IconApi size={14} />
<Text size="xs" fw={600}>
{t("customWidget.page.list.title")}
</Text>
</Group>
}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<SelectableCard
key={def.id}
onClick={() => void handleAddCustomWidget(def)}
aria-label={def.name}
icon={
def.iconUrl ? (
<Image src={def.iconUrl} w={24} h={24} fit="contain" style={{ flexShrink: 0 }} />
) : (
<ThemeIcon size={34} radius="md" variant="light" color="primaryColor">
<IconApi size={20} />
</ThemeIcon>
)
}
title={def.name}
description={def.description ?? ""}
footerLeft={
<Text size="xs" c="dimmed">
{t("item.create.customWidget")}
</Text>
}
/>
{filteredCustomWidgets.map((def) => (
<CatalogItem
key={def.id}
height={selectGridCardHeight}
label={def.name}
status={t("item.create.connectionStatus.noConnectionRequired")}
disabled={loadingSelection !== null}
busy={loadingSelection === `custom:${def.id}`}
onSelect={() => void handleAddCustomWidget(def)}
onFocus={() => {
void loadWidgetDefinition("customApi").catch(() => undefined);
}}
onPointerEnter={() => {
void loadWidgetDefinition("customApi").catch(() => undefined);
}}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
{def.iconUrl ? (
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
) : (
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
)}
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{def.name}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? ""}
</Text>
<ConnectionStatusBadge status="noConnectionRequired" />
</Stack>
</CatalogItem>
))}
))}
<Box mt="xs" style={{ gridColumn: "1 / -1" }}>
<Button
component="a"
href="/manage/custom-widgets/workshop"
target="_blank"
rel="noopener noreferrer"
variant="default"
fullWidth
leftSection={<IconBuildingStore size={16} />}
>
{t("workshop.browseWorkshop")}
</Button>
</Box>
</>
)}
</SimpleGrid>
<Box style={{ gridColumn: "1 / -1" }}>
{/* Opens in a new tab so unsaved board changes survive the detour. */}
<Button
component="a"
href="/manage/custom-widgets/workshop"
target="_blank"
rel="noopener noreferrer"
variant="default"
fullWidth
leftSection={<IconBuildingStore size={16} />}
>
{t("workshop.browseWorkshop")}
</Button>
</Box>
</>
)}
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
</Box>
</Flex>
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</Stack>
);
};
// =========================================================================
// WidgetItem: SelectableCard with Inset, Title, Status Badge, and Footer
// =========================================================================
export const WidgetItem = ({
item,
onSelect,
onFocus,
onPointerEnter,
disabled,
loading,
connectionStatus,
}: {
item: WidgetItemData;
onSelect: () => void;
onFocus?: () => void;
onPointerEnter?: () => void;
disabled: boolean;
loading: boolean;
connectionStatus: WidgetConnectionStatus;
}) => {
return (
<SelectableCard
disabled={disabled}
loading={loading}
onClick={onSelect}
onFocus={onFocus}
onPointerEnter={onPointerEnter}
aria-label={item.name}
icon={
<ThemeIcon size={34} radius="md" variant="light" color="primaryColor">
<item.icon size={20} />
</ThemeIcon>
}
title={item.name}
topRight={<ConnectionStatusBadge status={connectionStatus} />}
description={item.description}
footerLeft={<SupportedIntegrations integrations={item.supportedIntegrations} />}
/>
);
};
export const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
const t = useI18n();
if (status === "noConnectionRequired") return null;
const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
return (
<Badge variant="dot" color={color} size="xs" tt="none" w="fit-content" maw="100%">
{t(`item.create.connectionStatus.${status}`)}
</Badge>
);
};
export const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
const t = useI18n();
if (integrations.length === 0) {
return (
<Text size="xs" c="dimmed" fs="italic">
{t("item.create.standalone")}
</Text>
);
}
const countToShow = integrations.length >= 6 ? 4 : 5;
const moreCount = integrations.length - countToShow;
return (
<Group gap={2}>
<Tooltip.Group closeDelay={100}>
<Group gap={2}>
{integrations.slice(0, countToShow).map((integration) => (
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
<Avatar src={getIconUrl(integration)} size="xs" radius="xl" />
</Tooltip>
))}
{moreCount > 0 && (
<Tooltip
withArrow
label={
<Stack gap={2}>
{integrations.slice(countToShow).map((integration) => (
<Text key={integration} size="xs">
{getIntegrationName(integration)}
</Text>
))}
</Stack>
}
>
<Avatar radius="xl" size="xs">
+{moreCount}
</Avatar>
</Tooltip>
)}
</Group>
</Tooltip.Group>
</Group>
);
};
const ItemSelectModalFrame = ({
actions,
innerProps,
@@ -541,109 +660,3 @@ export const ItemSelectModal = createModal<{
defaultTitle: (t) => t("item.create.title"),
size: modalSizeSelect,
});
const WidgetItem = ({
item,
onSelect,
onFocus,
onPointerEnter,
disabled,
loading,
connectionStatus,
}: {
item: {
kind: WidgetKind;
supportedIntegrations: IntegrationKind[];
name: string;
description: string;
icon: TablerIcon;
};
onSelect: () => void;
onFocus: () => void;
onPointerEnter: () => void;
disabled: boolean;
loading: boolean;
connectionStatus: WidgetConnectionStatus;
}) => {
const t = useI18n();
const statusLabel = t(`item.create.connectionStatus.${connectionStatus}`);
return (
<CatalogItem
height={selectGridCardHeight}
label={item.name}
status={statusLabel}
onFocus={onFocus}
onPointerEnter={onPointerEnter}
onSelect={onSelect}
disabled={disabled}
busy={loading}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<item.icon size={22} style={{ flexShrink: 0, marginTop: 2 }} />
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{item.name}
</Text>
</Group>
<Tooltip label={item.description} multiline w={250} disabled={!item.description}>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{item.description}
</Text>
</Tooltip>
<ConnectionStatusBadge status={connectionStatus} />
<SupportedIntegrations integrations={item.supportedIntegrations} />
</Stack>
</CatalogItem>
);
};
const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
const t = useI18n();
const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
return (
<Badge variant="light" color={color} size="xs" tt="none" w="fit-content" maw="100%">
{t(`item.create.connectionStatus.${status}`)}
</Badge>
);
};
const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
if (integrations.length === 0) {
return null;
}
const countToShow = integrations.length >= 8 ? 6 : 7;
const moreCount = integrations.length - countToShow;
return (
<Group gap={2}>
<Tooltip.Group closeDelay={100}>
<Group gap={2}>
{integrations.slice(0, countToShow).map((integration) => (
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
<Avatar src={getIconUrl(integration)} size="xs" radius="xl" />
</Tooltip>
))}
{moreCount > 0 && (
<Tooltip
withArrow
label={
<>
{integrations.slice(countToShow).map((integration) => (
<div key={integration}>{getIntegrationName(integration)}</div>
))}
</>
}
>
<Avatar radius="xl" size="xs">
+{moreCount}
</Avatar>
</Tooltip>
)}
</Group>
</Tooltip.Group>
</Group>
);
};
@@ -1,16 +1,19 @@
"use client";
import { Suspense, use } from "react";
import { Suspense, use, useCallback, useMemo, useState } from "react";
import { Center, Loader } from "@mantine/core";
import { ErrorBoundary } from "react-error-boundary";
import type { FallbackProps } from "react-error-boundary";
import { clientApi } from "@homarr/api/client";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals";
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors";
import type * as WidgetModalsModule from "@homarr/widgets/modals";
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
@@ -80,13 +83,46 @@ interface LazyWidgetEditModalContentProps {
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const { WidgetEditModal } = use(loadWidgetEditModal());
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const Component = WidgetEditModal.component;
const utils = clientApi.useUtils();
const [createdIntegrations, setCreatedIntegrations] = useState<IntegrationSelectOption[]>([]);
const supportedKinds = useMemo(() => {
if (!("supportedIntegrations" in props.innerProps.definition)) return [];
return (props.innerProps.definition.supportedIntegrations ?? []).filter((k) => k !== "mock");
}, [props.innerProps.definition]);
const handleOpenNewIntegration = useCallback(
(onCreated?: (id: string) => void) => {
openIntegrationModal({
allowedKinds: supportedKinds,
onSuccess: (result) => {
if (result?.integration) {
setCreatedIntegrations((prev) => [...prev, result.integration]);
void utils.integration.all.invalidate();
onCreated?.(result.integration.id);
}
},
});
},
[openIntegrationModal, supportedKinds, utils],
);
const combinedIntegrationData = useMemo(() => {
const existing = props.innerProps.integrationData;
const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id));
return [...existing, ...additional];
}, [props.innerProps.integrationData, createdIntegrations]);
return (
<Component
{...props}
innerProps={{
...props.innerProps,
integrationData: combinedIntegrationData,
integrationEditForm: LazyIntegrationEditForm,
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
}}
/>
);
@@ -122,6 +158,5 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
return t("item.edit.title");
},
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: false,
});