diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx
index 6f1710e3b..5c1407572 100644
--- a/apps/docs/docs/management/boards/index.mdx
+++ b/apps/docs/docs/management/boards/index.mdx
@@ -204,10 +204,13 @@ changes, Homarr asks whether to discard them. If the editor cannot load, the boa
style={{ maxWidth: "100%" }}
/>
-While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
-Spotlight. It lists the available actions directly and can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
-current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as
-destinations before the widget is created, so the common flow does not require a second move step.
+While edit mode is active, **Add board content** opens a menu for adding a widget, app, integration, or Container. The
+available actions respect the current user's permissions. Widget setup offers the main canvas, enabled rails, and existing
+Containers as destinations before the widget is created, so the common flow does not require a second move step.
+
+Hold `Ctrl` on Windows or Linux, or `⌘` on macOS, while clicking board items to select several at once. The selection
+toolbar can move the complete selection to another canvas, rail, or Container, delete it with an undo option, or clear the
+selection. A move is applied only when every selected item fits in the destination.
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
@@ -227,11 +230,7 @@ During a pointer or touch interaction, Escape cancels the move or resize and res
Invalid, outside, or otherwise forbidden drops are cancelled in the same way.
The **Move / resize item** action in the edit menu remains available for precise numeric input.
-
+
#### Preview
diff --git a/apps/docs/docs/widgets/today/index.mdx b/apps/docs/docs/widgets/today/index.mdx
deleted file mode 100644
index 000861d20..000000000
--- a/apps/docs/docs/widgets/today/index.mdx
+++ /dev/null
@@ -1,34 +0,0 @@
----
-title: "Today"
-description: "Displays the current date and progress through the calendar."
-hide_title: true
----
-
-import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
-
-import { AddingWidget } from "@site/src/components/widgets/adding";
-import { WidgetConfig } from "@site/src/components/widgets/configuration";
-import { WidgetHeader } from "@site/src/components/widgets/header";
-import { todayWidget } from ".";
-
-
-
-The Today widget presents the current local date without duplicating a live clock. It progressively adds the week
-number, day of year, quarter, remaining days, and calendar progress as more space becomes available.
-
-The advanced view shows week, month, and year progress together with a twelve-month overview. Week numbers can follow
-your locale or the ISO 8601 convention. “Days after today” excludes the current day to keep the remaining-day count
-unambiguous.
-
-The widget runs entirely in your browser and does not send date or location data to an external service. It follows
-the timezone and locale of the device viewing the board, so viewers in different regions can see their own local date.
-
-
-
-### Adding the widget
-
-
-
-### Configuration
-
-
diff --git a/apps/docs/docs/widgets/today/index.ts b/apps/docs/docs/widgets/today/index.ts
deleted file mode 100644
index 8922d4b6e..000000000
--- a/apps/docs/docs/widgets/today/index.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import type { WidgetDefinition } from "@site/src/types";
-import { IconCalendarWeek } from "@tabler/icons-react";
-
-export const todayWidget: WidgetDefinition = {
- icon: IconCalendarWeek,
- name: "Today",
- description: "Displays the current date and progress through the calendar.",
- path: "../../widgets/today",
- configuration: {
- items: [
- {
- name: "Week numbering",
- description: "Use locale-specific week numbering or ISO 8601 week numbers",
- values: { type: "select", options: ["Use locale rules", "ISO 8601"] },
- defaultValue: "Use locale rules",
- },
- {
- name: "Show week number",
- description: "Display the current week number when the widget has room",
- values: { type: "boolean" },
- defaultValue: "yes",
- },
- {
- name: "Show day of year",
- description: "Display the current day number within the year",
- values: { type: "boolean" },
- defaultValue: "yes",
- },
- {
- name: "Show quarter",
- description: "Display the current calendar quarter",
- values: { type: "boolean" },
- defaultValue: "yes",
- },
- {
- name: "Show year progress",
- description: "Display progress through the current year",
- values: { type: "boolean" },
- defaultValue: "yes",
- },
- ],
- },
-};
diff --git a/apps/docs/static/api/open-api-schema.json b/apps/docs/static/api/open-api-schema.json
index 8e5da38c1..83b48a665 100644
--- a/apps/docs/static/api/open-api-schema.json
+++ b/apps/docs/static/api/open-api-schema.json
@@ -2106,7 +2106,6 @@
"enum": [
"clock",
"weather",
- "today",
"airQuality",
"countdown",
"timer",
diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx
index bfd8d33ec..9fa4b7c5d 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx
@@ -23,6 +23,8 @@ import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host";
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
import { BoardBackgroundVideo } from "~/components/layout/background";
+import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context";
+import { BoardSelectionToolbar } from "~/components/board/selection/board-selection-toolbar";
import classes from "./_client.module.css";
const APP_SHELL_INLINE_PADDING = 32;
@@ -63,71 +65,74 @@ export const ClientBoard = () => {
.join(" ");
const content = (
-
-
-
-
-
-
-
-
-
-
-
- {leftColumnCount > 0 && leftSection && (
-
- )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {leftColumnCount > 0 && leftSection && (
+
+ )}
-
-
- {rightColumnCount > 0 && rightSection && (
-
- )}
-
-
-
-
-
-
-
+
+
+ {rightColumnCount > 0 && rightSection && (
+
+ )}
+
+
+
+
+
+
+
+
+
);
if (representativeWidth === null) return content;
diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx
index 87eda8813..0c9404897 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx
@@ -2,14 +2,19 @@
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { IconPlus } from "@tabler/icons-react";
+import { Group, Menu } from "@mantine/core";
+import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
+import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
+import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
-import { useBoardAddActions } from "~/components/board/use-board-add-actions";
-import { UniversalCreateModal } from "~/components/create/universal-create-modal";
+import { useItemActions } from "~/components/board/items/item-actions";
+import { ItemSelectModal } from "~/components/board/items/item-select-modal";
+import { useContainerActions } from "~/components/board/sections/container/container-actions";
+import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export default function BoardEditActions() {
@@ -17,9 +22,13 @@ export default function BoardEditActions() {
}
const AddMenu = () => {
+ const { data: session } = useSession();
const board = useRequiredBoard();
- const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
- const { openModal } = useModalAction(UniversalCreateModal);
+ const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
+ const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
+ const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
+ const { addContainer } = useContainerActions();
+ const { createItem } = useItemActions();
const t = useI18n();
const pathname = usePathname();
const router = useRouter();
@@ -29,29 +38,69 @@ const AddMenu = () => {
useEffect(() => {
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
handledInitialAdd.current = true;
- addWidget();
+ openItemSelectModal({ boardId: board.id });
const nextSearchParams = new URLSearchParams(searchParams.toString());
nextSearchParams.delete("add");
const query = nextSearchParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
- }, [addWidget, board.id, pathname, router, searchParams]);
+ }, [board.id, openItemSelectModal, pathname, router, searchParams]);
+
+ const handleSelectItem = () => {
+ openItemSelectModal({ boardId: board.id });
+ };
+
+ const handleSelectApp = () => {
+ openAppSelectModal({
+ onSelect: (app) => {
+ createItem({
+ kind: "app",
+ options: { appId: app.id },
+ });
+ },
+ onSelectMany: (apps) => {
+ for (const app of apps) {
+ createItem({
+ kind: "app",
+ options: { appId: app.id },
+ });
+ }
+ },
+ withCreate: session?.user.permissions.includes("app-create") ?? false,
+ });
+ };
+
+ const handleAddIntegration = () => {
+ openIntegrationSelectModal({ completionBoardId: board.id });
+ };
return (
-
- openModal({
- boardActions: {
- widget: addWidget,
- app: addApp,
- integration: canConnectService ? connectService : undefined,
- container: addContainer,
- },
- })
- }
- >
-
-
+
+
+
+
+
+
+
+
+
+
+ } onClick={handleSelectItem}>
+ {t("item.action.create")}
+
+ } onClick={handleSelectApp}>
+ {t("app.action.add")}
+
+ {session?.user.permissions.includes("integration-create") && (
+ } onClick={handleAddIntegration}>
+ {t("integration.action.create")}
+
+ )}
+
+ } onClick={addContainer}>
+ {t("section.container.action.create")}
+
+
+
);
};
diff --git a/apps/nextjs/src/components/board/audio/board-sounds.ts b/apps/nextjs/src/components/board/audio/board-sounds.ts
new file mode 100644
index 000000000..3d8c4bce8
--- /dev/null
+++ b/apps/nextjs/src/components/board/audio/board-sounds.ts
@@ -0,0 +1,123 @@
+/**
+ * Procedural UI sounds for Homarr using the Web Audio API.
+ * Zero audio files shipped. Pure runtime synthesis with oscillators, noise buffers, and gain envelopes.
+ */
+
+let audioContext: AudioContext | null = null;
+
+const getAudioContext = (): AudioContext | null => {
+ if (typeof window === "undefined") return null;
+
+ try {
+ if (!audioContext) {
+ const AudioContextClass =
+ window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
+ if (AudioContextClass) audioContext = new AudioContextClass();
+ }
+ if (audioContext?.state === "suspended") void audioContext.resume().catch(() => undefined);
+ return audioContext;
+ } catch {
+ return null;
+ }
+};
+
+/**
+ * Creates a white noise audio buffer for percussive textures
+ */
+const createNoiseBuffer = (ctx: AudioContext, durationSeconds = 0.2): AudioBuffer => {
+ const bufferSize = Math.floor(ctx.sampleRate * durationSeconds);
+ const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
+ const data = buffer.getChannelData(0);
+ for (let i = 0; i < bufferSize; i++) {
+ data[i] = Math.random() * 2 - 1;
+ }
+ return buffer;
+};
+
+/**
+ * Procedural trash / delete crunch sound.
+ * Synthesizes a crunchy bin/paper crumple noise burst and downward pitch thud.
+ */
+export const playTrashSound = (volume = 0.35) => {
+ const ctx = getAudioContext();
+ if (!ctx) return;
+
+ try {
+ const now = ctx.currentTime;
+
+ // Master Gain
+ const masterGain = ctx.createGain();
+ masterGain.gain.setValueAtTime(volume, now);
+ masterGain.connect(ctx.destination);
+
+ // 1. Downward sweep sub-oscillator (physical thud)
+ const osc = ctx.createOscillator();
+ const oscGain = ctx.createGain();
+ osc.type = "triangle";
+ osc.frequency.setValueAtTime(260, now);
+ osc.frequency.exponentialRampToValueAtTime(35, now + 0.18);
+ oscGain.gain.setValueAtTime(0.7, now);
+ oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18);
+ osc.connect(oscGain);
+ oscGain.connect(masterGain);
+ osc.start(now);
+ osc.stop(now + 0.2);
+
+ // 2. High-crunch noise burst (crush / crumple texture)
+ try {
+ const noiseBuffer = createNoiseBuffer(ctx, 0.22);
+ const noiseSource = ctx.createBufferSource();
+ noiseSource.buffer = noiseBuffer;
+
+ const noiseFilter = ctx.createBiquadFilter();
+ noiseFilter.type = "bandpass";
+ noiseFilter.frequency.setValueAtTime(1400, now);
+ noiseFilter.frequency.exponentialRampToValueAtTime(320, now + 0.2);
+ noiseFilter.Q.setValueAtTime(2.2, now);
+
+ const noiseGain = ctx.createGain();
+ noiseGain.gain.setValueAtTime(0.9, now);
+ noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
+
+ noiseSource.connect(noiseFilter);
+ noiseFilter.connect(noiseGain);
+ noiseGain.connect(masterGain);
+
+ noiseSource.start(now);
+ noiseSource.stop(now + 0.22);
+ } catch {
+ // The noise layer is optional; retain the oscillator when buffers are constrained.
+ }
+ } catch {
+ // Sound feedback must never interrupt deleting board items.
+ }
+};
+
+/**
+ * Procedural select / pop sound.
+ */
+export const playPopSound = (volume = 0.25) => {
+ const ctx = getAudioContext();
+ if (!ctx) return;
+
+ try {
+ const now = ctx.currentTime;
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+
+ osc.type = "sine";
+ osc.frequency.setValueAtTime(460, now);
+ osc.frequency.exponentialRampToValueAtTime(780, now + 0.05);
+
+ gain.gain.setValueAtTime(volume, now);
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
+
+ osc.connect(gain);
+ gain.connect(ctx.destination);
+
+ osc.start(now);
+ osc.stop(now + 0.07);
+ } catch {
+ // Sound feedback must never interrupt selecting board items.
+ }
+};
diff --git a/apps/nextjs/src/components/board/items/item-content.module.css b/apps/nextjs/src/components/board/items/item-content.module.css
index 7100f3197..7ec4b3a16 100644
--- a/apps/nextjs/src/components/board/items/item-content.module.css
+++ b/apps/nextjs/src/components/board/items/item-content.module.css
@@ -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;
+ }
+}
diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx
index 0679edde3..55e998125 100644
--- a/apps/nextjs/src/components/board/items/item-menu.tsx
+++ b/apps/nextjs/src/components/board/items/item-menu.tsx
@@ -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 })}
>
diff --git a/apps/nextjs/src/components/board/items/item-select-data.spec.ts b/apps/nextjs/src/components/board/items/item-select-data.spec.ts
index a9527a3ba..cc269c8b6 100644
--- a/apps/nextjs/src/components/board/items/item-select-data.spec.ts
+++ b/apps/nextjs/src/components/board/items/item-select-data.spec.ts
@@ -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(["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(),
+ 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");
});
});
diff --git a/apps/nextjs/src/components/board/items/item-select-modal.module.css b/apps/nextjs/src/components/board/items/item-select-modal.module.css
deleted file mode 100644
index 1a53dd49b..000000000
--- a/apps/nextjs/src/components/board/items/item-select-modal.module.css
+++ /dev/null
@@ -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;
- }
-}
diff --git a/apps/nextjs/src/components/board/items/item-select-modal.spec.tsx b/apps/nextjs/src/components/board/items/item-select-modal.spec.tsx
new file mode 100644
index 000000000..ee589bf57
--- /dev/null
+++ b/apps/nextjs/src/components/board/items/item-select-modal.spec.tsx
@@ -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 = {
+ "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>("@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(
+
+
+ ,
+ ),
+ );
+
+ 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(
+
+
+ ,
+ ),
+ );
+
+ expect(host.textContent).toContain("Needs setup");
+ });
+
+ it("renders null when status is 'noConnectionRequired'", async () => {
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ 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(
+
+
+ ,
+ ),
+ );
+
+ // 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(
+
+
+ ,
+ ),
+ );
+
+ // 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(
+
+
+ ,
+ ),
+ );
+
+ expect(host.textContent).toContain("Standalone");
+ });
+ });
+
+ describe("WidgetItem", () => {
+ const FakeIcon = () => Icon ;
+
+ 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(
+
+
+ ,
+ ),
+ );
+
+ 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(
+
+
+ ,
+ ),
+ );
+
+ const button = host.querySelector("button");
+ expect(button?.hasAttribute("data-selected")).toBe(false);
+ });
+ });
+});
diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx
index 2d163c89d..1b53094a9 100644
--- a/apps/nextjs/src/components/board/items/item-select-modal.tsx
+++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx
@@ -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(placementOptions[0]?.value ?? null);
+ const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId)
+ ? targetSectionId
+ : (placementOptions[0]?.value ?? null);
+
const [loadingSelection, setLoadingSelection] = useState(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(
() =>
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 (
-
-
- {
- if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
- const [item] = filteredItems;
- if (item) void handleAdd(item.kind);
- }
- }}
- >
- {placementOptions.length > 1 && (
-
-
-
- )}
- {filteredItems.map((item) => (
- 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),
- })}
+
+ {/* Top Search & Destination Selector */}
+
+
+ setSearch(event.currentTarget.value)}
+ leftSection={ }
+ 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);
+ }
+ }}
+ />
+
+
+ {placementOptions.length > 1 && (
+
+
- ))}
+
+ )}
+
- {isAdmin && (
- <>
-
+ {filteredItems.map((item) => (
+ 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 && (
+ <>
+
+
+
+ {t("customWidget.page.list.title")}
+
+
+ }
+ labelPosition="center"
+ my="sm"
+ style={{ gridColumn: "1 / -1" }}
+ />
+ {filteredCustomWidgets.map((def) => (
+ void handleAddCustomWidget(def)}
+ aria-label={def.name}
+ icon={
+ def.iconUrl ? (
+
+ ) : (
+
+
+
+ )
+ }
+ title={def.name}
+ description={def.description ?? ""}
+ footerLeft={
+
+ {t("item.create.customWidget")}
+
+ }
/>
- {filteredCustomWidgets.map((def) => (
- void handleAddCustomWidget(def)}
- onFocus={() => {
- void loadWidgetDefinition("customApi").catch(() => undefined);
- }}
- onPointerEnter={() => {
- void loadWidgetDefinition("customApi").catch(() => undefined);
- }}
- >
-
-
- {def.iconUrl ? (
-
- ) : (
-
- )}
-
- {def.name}
-
-
-
- {def.description ?? ""}
-
-
-
-
- ))}
+ ))}
+
+ }
+ >
+ {t("workshop.browseWorkshop")}
+
+
+ >
+ )}
+
-
- {/* Opens in a new tab so unsaved board changes survive the detour. */}
- }
- >
- {t("workshop.browseWorkshop")}
-
-
- >
- )}
-
- {filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
-
- {t("common.noResults")}
-
- )}
-
-
-
+ {filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
+
+ {t("common.noResults")}
+
+ )}
+
);
};
+
+// =========================================================================
+// 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 (
+
+
+
+ }
+ title={item.name}
+ topRight={ }
+ description={item.description}
+ footerLeft={ }
+ />
+ );
+};
+
+export const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
+ const t = useI18n();
+ if (status === "noConnectionRequired") return null;
+ const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
+
+ return (
+
+ {t(`item.create.connectionStatus.${status}`)}
+
+ );
+};
+
+export const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
+ const t = useI18n();
+
+ if (integrations.length === 0) {
+ return (
+
+ {t("item.create.standalone")}
+
+ );
+ }
+
+ const countToShow = integrations.length >= 6 ? 4 : 5;
+ const moreCount = integrations.length - countToShow;
+
+ return (
+
+
+
+ {integrations.slice(0, countToShow).map((integration) => (
+
+
+
+ ))}
+ {moreCount > 0 && (
+
+ {integrations.slice(countToShow).map((integration) => (
+
+ {getIntegrationName(integration)}
+
+ ))}
+
+ }
+ >
+
+ +{moreCount}
+
+
+ )}
+
+
+
+ );
+};
+
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 (
-
-
-
-
-
- {item.name}
-
-
-
-
- {item.description}
-
-
-
-
-
-
- );
-};
-
-const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
- const t = useI18n();
- const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
-
- return (
-
- {t(`item.create.connectionStatus.${status}`)}
-
- );
-};
-
-const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
- if (integrations.length === 0) {
- return null;
- }
-
- const countToShow = integrations.length >= 8 ? 6 : 7;
- const moreCount = integrations.length - countToShow;
-
- return (
-
-
-
- {integrations.slice(0, countToShow).map((integration) => (
-
-
-
- ))}
- {moreCount > 0 && (
-
- {integrations.slice(countToShow).map((integration) => (
- {getIntegrationName(integration)}
- ))}
- >
- }
- >
-
- +{moreCount}
-
-
- )}
-
-
-
- );
-};
diff --git a/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx
index fab8d4d2e..0a6f448b2 100644
--- a/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx
+++ b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx
@@ -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([]);
+
+ 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 (
0 ? handleOpenNewIntegration : undefined,
}}
/>
);
@@ -122,6 +158,5 @@ export const LazyWidgetEditModal = createModal>
return t("item.edit.title");
},
size: modalSizeForm,
- presentation: "inspector",
closeOnClickOutside: false,
});
diff --git a/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx b/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx
index 17a8ddafb..1525c71ea 100644
--- a/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx
@@ -17,6 +17,7 @@ import {
} from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
+import { useBoardSelection } from "~/components/board/selection/board-selection-context";
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd";
import type { GridTransaction } from "./dnd";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
@@ -48,6 +49,16 @@ export const FixedGridItem = ({
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
const { commitSectionGrid } = useGridLayoutActions();
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
+ const { isSelected, toggleSelectItem } = useBoardSelection();
+ const selected = item.type === "item" && isSelected(item.id);
+
+ const handleClickCapture = (event: React.MouseEvent) => {
+ if (!isEditorActive || item.type !== "item" || (!event.metaKey && !event.ctrlKey)) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ toggleSelectItem(item.id);
+ };
const instructionsId = useId();
const isEditorActive = isEditMode && runtimeStatus === "ready";
const placement = toPlacement(item);
@@ -178,6 +189,8 @@ export const FixedGridItem = ({
return (
svg {
pointer-events: none;
}
-body[data-board-grid-interacting] .board-grid-resize-handle::after {
- opacity: 0;
-}
-
-.board-grid-resize-handle::after {
- position: absolute;
- inset: 50% auto auto 50%;
- width: 12px;
- height: 3px;
- content: "";
- background: color-mix(in srgb, var(--mantine-color-text) 70%, transparent);
- border-radius: var(--mantine-radius-xl);
- opacity: 0;
- transform: translate(-50%, -50%);
- transition: opacity 100ms ease;
-}
-
-.board-grid-entry:hover > .board-grid-resize-handle::after,
-.board-grid-resize-handle:focus-visible::after {
+.board-grid-entry:has(> .board-grid-resize-handle:hover) > .board-grid-resize-handle,
+.board-grid-entry:has(> .board-grid-resize-handle:active) > .board-grid-resize-handle {
+ border-color: var(--mantine-color-dimmed);
opacity: 1;
-}
-
-.board-grid-resize-handle:focus-visible {
- outline: 2px solid var(--mantine-primary-color-filled);
- outline-offset: -8px;
+ scale: 1;
}
.board-grid-resize-handle[data-grid-resize-handle="n"],
.board-grid-resize-handle[data-grid-resize-handle="s"] {
- right: 44px;
- left: 44px;
- height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
+ left: 50%;
+ width: 1.75rem;
+ height: 1rem;
+ border-radius: var(--mantine-radius-xl);
+ translate: -50% 0;
cursor: ns-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="n"] {
- top: 0;
+ top: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="s"] {
- bottom: 0;
+ bottom: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="e"],
.board-grid-resize-handle[data-grid-resize-handle="w"] {
- top: 44px;
- bottom: 44px;
- width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
+ top: 50%;
+ width: 1rem;
+ height: 1.75rem;
+ border-radius: var(--mantine-radius-xl);
+ translate: 0 -50%;
cursor: ew-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="e"] {
- right: 0;
+ right: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="w"] {
- left: 0;
-}
-
-.board-grid-resize-handle[data-grid-resize-handle="e"]::after,
-.board-grid-resize-handle[data-grid-resize-handle="w"]::after {
- width: 3px;
- height: 12px;
+ left: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="ne"],
.board-grid-resize-handle[data-grid-resize-handle="se"],
.board-grid-resize-handle[data-grid-resize-handle="sw"],
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
- z-index: 9;
- width: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
- height: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
+ width: 1.875rem;
+ height: 1.875rem;
+ border-radius: var(--board-item-radius, var(--mantine-radius-md));
}
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
- top: 0;
- right: 0;
+ top: -0.625rem;
+ right: -0.625rem;
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="se"] {
- right: 0;
- bottom: 0;
+ right: -0.625rem;
+ bottom: -0.625rem;
cursor: nwse-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
- bottom: 0;
- left: 0;
+ bottom: -0.625rem;
+ left: -0.625rem;
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
- top: 0;
- left: 0;
+ top: -0.625rem;
+ left: -0.625rem;
cursor: nwse-resize;
}
-.board-grid-resize-handle[data-grid-resize-handle="ne"]::after,
-.board-grid-resize-handle[data-grid-resize-handle="sw"]::after {
- transform: translate(-50%, -50%) rotate(-45deg);
-}
-
-.board-grid-resize-handle[data-grid-resize-handle="se"]::after,
-.board-grid-resize-handle[data-grid-resize-handle="nw"]::after {
- transform: translate(-50%, -50%) rotate(45deg);
-}
-
-@media (hover: none), (pointer: coarse) {
- .board-grid-resize-handle::after {
- opacity: 1;
- }
-}
-
@media (prefers-reduced-motion: reduce) {
.board-grid-entry,
.board-grid-placeholder,
.board-grid-resize-outline,
- .board-grid-resize-handle::after {
+ .board-grid-resize-handle {
transition: none;
}
}
diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
index 30b43b1bf..428e2a939 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
@@ -32,6 +32,7 @@ import type {
DragStartEvent,
} from "@dnd-kit/react";
import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDroppable } from "@dnd-kit/react";
+import { IconGripHorizontal, IconGripVertical } from "@tabler/icons-react";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
@@ -61,6 +62,7 @@ import { useBoardGridPortalHost } from "./grid-portal-host";
import type { GridInteraction } from "./grid-preview-layer";
import { GridPreviewLayer } from "./grid-preview-layer";
import { createGridResizeOutlineController } from "./grid-resize-outline";
+import { useBoardSelection } from "~/components/board/selection/board-selection-context";
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions";
@@ -846,6 +848,9 @@ const DndGridEntryComponent = ({
}: DndGridEntryProps) => {
const { acquireContainer, structureRevision } = useBoardGridPortalHost();
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
+ const { isSelected } = useBoardSelection();
+ const board = useRequiredBoard();
+ const isSelectedEntry = placement.type === "item" && isSelected(placement.id);
const mountRef = useRef(null);
const shellRef = useRef(null);
const placementRef = useRef(placement);
@@ -921,7 +926,12 @@ const DndGridEntryComponent = ({
(
- handlePointerDown(event, direction)}
- onPointerMove={handlePointerMove}
- onPointerUp={(event) => handlePointerEnd(event, false)}
- onPointerCancel={(event) => handlePointerEnd(event, true)}
- onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
- />
- ));
+ return RESIZE_DIRECTIONS.map((direction) => {
+ const isHorizontalSide = direction === "n" || direction === "s";
+ const isVerticalSide = direction === "e" || direction === "w";
+ const GripIcon = isHorizontalSide ? IconGripHorizontal : IconGripVertical;
+
+ return (
+ handlePointerDown(event, direction)}
+ onPointerMove={handlePointerMove}
+ onPointerUp={(event) => handlePointerEnd(event, false)}
+ onPointerCancel={(event) => handlePointerEnd(event, true)}
+ onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
+ >
+
+
+ );
+ });
};
const getProjectedDragShape = (
@@ -1239,6 +1258,7 @@ const getPreferredRegisteredGridTarget = (
const preventGridDragActivation = (event: PointerEvent, source: DomDraggable) => {
if (document.body.hasAttribute("data-board-grid-interacting")) return true;
+ if (event.metaKey || event.ctrlKey) return true;
const target = event.target;
const activationElement = source.handle ?? source.element;
if (!(target instanceof Element) || target === activationElement) return false;
diff --git a/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts
index 12ed7e84d..858d68cb3 100644
--- a/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts
+++ b/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts
@@ -10,6 +10,8 @@ import { FixedGridItem } from "../fixed-grid-item";
const mocks = vi.hoisted(() => ({
announce: vi.fn(),
commitSectionGrid: vi.fn(),
+ isSelected: vi.fn(() => false),
+ toggleSelectItem: vi.fn(),
editMode: true,
maxRowCount: null as number | null,
items: [
@@ -44,6 +46,13 @@ vi.mock("@homarr/boards/edit-mode", () => ({
useEditMode: () => [mocks.editMode],
}));
+vi.mock("~/components/board/selection/board-selection-context", () => ({
+ useBoardSelection: () => ({
+ isSelected: mocks.isSelected,
+ toggleSelectItem: mocks.toggleSelectItem,
+ }),
+}));
+
vi.mock("@homarr/boards/context", () => ({
useCurrentLayout: () => "layout",
}));
diff --git a/apps/nextjs/src/components/board/selection/board-selection-context.tsx b/apps/nextjs/src/components/board/selection/board-selection-context.tsx
new file mode 100644
index 000000000..c786faff1
--- /dev/null
+++ b/apps/nextjs/src/components/board/selection/board-selection-context.tsx
@@ -0,0 +1,270 @@
+"use client";
+
+import type { PropsWithChildren } from "react";
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
+import { Button, Group, Text } from "@mantine/core";
+import { notifications } from "@mantine/notifications";
+
+import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
+import { useEditMode } from "@homarr/boards/edit-mode";
+import { usePersistBoard } from "@homarr/boards/updater";
+import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
+import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
+import { useI18n, useScopedI18n } from "@homarr/translation/client";
+
+import type { ContainerSection, ItemLayout } from "~/app/[locale]/boards/_types";
+import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
+import { playPopSound, playTrashSound } from "../audio/board-sounds";
+
+interface BoardSelectionContextValue {
+ selectedItemIds: Set;
+ toggleSelectItem: (id: string) => void;
+ clearSelection: () => void;
+ isSelected: (id: string) => boolean;
+ removeSelectedItems: () => void;
+ moveSelectedItemsToSection: (sectionId: string) => void;
+}
+
+const BoardSelectionContext = createContext(null);
+
+export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
+ const [selectedItemIds, setSelectedItemIds] = useState>(new Set());
+ const [isEditMode] = useEditMode();
+ const board = useRequiredBoard();
+ const currentLayoutId = useCurrentLayout();
+ const { updateAndPersistBoard } = usePersistBoard(board);
+ const t = useI18n();
+ const tSelection = useScopedI18n("item.selection");
+
+ const persistBoard = useCallback(
+ (updater: (previous: typeof board) => typeof board) => {
+ updateAndPersistBoard(updater, {
+ onError: () => {
+ showErrorNotification({
+ title: tSelection("saveErrorTitle"),
+ message: tSelection("saveErrorMessage"),
+ });
+ },
+ });
+ },
+ [tSelection, updateAndPersistBoard],
+ );
+
+ // Clear selection if leaving edit mode
+ useEffect(() => {
+ if (!isEditMode) {
+ setSelectedItemIds(new Set());
+ }
+ }, [isEditMode]);
+
+ // Clear selection on Escape key
+ useEffect(() => {
+ if (selectedItemIds.size === 0) return;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ setSelectedItemIds(new Set());
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [selectedItemIds.size]);
+
+ const toggleSelectItem = useCallback((id: string) => {
+ setSelectedItemIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ return next;
+ });
+ playPopSound();
+ }, []);
+
+ const clearSelection = useCallback(() => {
+ setSelectedItemIds(new Set());
+ }, []);
+
+ const isSelected = useCallback((id: string) => selectedItemIds.has(id), [selectedItemIds]);
+
+ const removeSelectedItems = useCallback(() => {
+ if (selectedItemIds.size === 0) return;
+
+ const idsToRemove = Array.from(selectedItemIds);
+ const count = idsToRemove.length;
+ const removedItems = board.items.filter((item) => idsToRemove.includes(item.id));
+
+ playTrashSound();
+ clearSelection();
+
+ persistBoard((previous) => ({
+ ...previous,
+ items: previous.items.filter((item) => !idsToRemove.includes(item.id)),
+ }));
+
+ const notificationId = `batch-remove-${Date.now()}`;
+ showSuccessNotification({
+ id: notificationId,
+ title: t("common.success"),
+ message: (
+
+ {tSelection("removed", { count })}
+ {
+ persistBoard((previous) => ({
+ ...previous,
+ items: [...previous.items, ...removedItems],
+ }));
+ notifications.hide(notificationId);
+ }}
+ >
+ {t("common.action.undo")}
+
+
+ ),
+ autoClose: 10_000,
+ });
+ }, [board.items, clearSelection, persistBoard, selectedItemIds, t, tSelection]);
+
+ const moveSelectedItemsToSection = useCallback(
+ (targetSectionId: string) => {
+ if (selectedItemIds.size === 0) return;
+
+ const idsToMove = new Set(selectedItemIds);
+ let didMoveAll = false;
+
+ persistBoard((previous) => {
+ const targetSection = previous.sections.find((section) => section.id === targetSectionId);
+ const boardLayout = previous.layouts.find((layout) => layout.id === currentLayoutId);
+ if (!targetSection || !boardLayout) return previous;
+
+ const containerLayout =
+ targetSection.kind === "container"
+ ? targetSection.layouts.find((layout) => layout.layoutId === currentLayoutId)
+ : undefined;
+ const columnCount =
+ targetSection.kind === "container"
+ ? (containerLayout?.width ?? 0)
+ : getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
+ const rowCount = containerLayout?.height ?? 9999;
+ if (columnCount === 0 || rowCount === 0) return previous;
+
+ const existingContainers = previous.sections
+ .filter((section): section is ContainerSection => section.kind === "container")
+ .flatMap((section) => {
+ const layout = section.layouts.find(
+ (candidate) => candidate.layoutId === currentLayoutId && candidate.parentSectionId === targetSection.id,
+ );
+ return layout
+ ? [
+ {
+ id: section.id,
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ width: layout.width,
+ height: layout.height,
+ },
+ ]
+ : [];
+ });
+ const existingItems = previous.items.flatMap((item) => {
+ const layout = item.layouts.find(
+ (candidate) => candidate.layoutId === currentLayoutId && candidate.sectionId === targetSection.id,
+ );
+ return layout
+ ? [
+ {
+ id: item.id,
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ width: layout.width,
+ height: layout.height,
+ },
+ ]
+ : [];
+ });
+ const occupiedPositions = [...existingContainers, ...existingItems];
+ const movedLayouts = new Map();
+
+ for (const item of previous.items.filter((candidate) => idsToMove.has(candidate.id))) {
+ const currentItemLayout = item.layouts.find((layout) => layout.layoutId === currentLayoutId);
+ if (!currentItemLayout) continue;
+
+ const previousPositionIndex = occupiedPositions.findIndex((position) => position.id === item.id);
+ const previousPosition =
+ previousPositionIndex >= 0 ? occupiedPositions.splice(previousPositionIndex, 1).at(0) : undefined;
+ const { width, height } = currentItemLayout;
+ const emptyPosition =
+ width <= columnCount && height <= rowCount
+ ? getFirstEmptyPosition(occupiedPositions, columnCount, rowCount, { width, height })
+ : undefined;
+
+ if (!emptyPosition) {
+ if (previousPosition) occupiedPositions.push(previousPosition);
+ continue;
+ }
+
+ const nextLayout: ItemLayout = {
+ ...currentItemLayout,
+ sectionId: targetSectionId,
+ xOffset: emptyPosition.xOffset,
+ yOffset: emptyPosition.yOffset,
+ };
+ movedLayouts.set(item.id, nextLayout);
+ occupiedPositions.push({ id: item.id, ...emptyPosition, width, height });
+ }
+
+ if (movedLayouts.size !== idsToMove.size) return previous;
+ didMoveAll = true;
+ return {
+ ...previous,
+ items: previous.items.map((item) => {
+ const movedLayout = movedLayouts.get(item.id);
+ if (!movedLayout) return item;
+ return {
+ ...item,
+ layouts: item.layouts.map((layout) => (layout.layoutId === currentLayoutId ? movedLayout : layout)),
+ };
+ }),
+ };
+ });
+
+ if (didMoveAll) {
+ clearSelection();
+ } else {
+ showErrorNotification({
+ title: tSelection("moveUnavailableTitle"),
+ message: tSelection("moveUnavailableMessage"),
+ });
+ }
+ },
+ [clearSelection, currentLayoutId, persistBoard, selectedItemIds, tSelection],
+ );
+
+ const value = useMemo(
+ () => ({
+ selectedItemIds,
+ toggleSelectItem,
+ clearSelection,
+ isSelected,
+ removeSelectedItems,
+ moveSelectedItemsToSection,
+ }),
+ [clearSelection, isSelected, moveSelectedItemsToSection, removeSelectedItems, selectedItemIds, toggleSelectItem],
+ );
+
+ return {children} ;
+};
+
+export const useBoardSelection = () => {
+ const context = useContext(BoardSelectionContext);
+ if (!context) {
+ throw new Error("useBoardSelection must be used within a BoardSelectionProvider");
+ }
+ return context;
+};
diff --git a/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx b/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx
new file mode 100644
index 000000000..0f9bbc3c4
--- /dev/null
+++ b/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx
@@ -0,0 +1,255 @@
+"use client";
+
+import {
+ ActionIcon,
+ Avatar,
+ Button,
+ Divider,
+ Group,
+ HoverCard,
+ Menu,
+ Paper,
+ ScrollArea,
+ Stack,
+ Text,
+ Tooltip,
+ UnstyledButton,
+} from "@mantine/core";
+import { IconCheck, IconFolderShare, IconTrash, IconX } from "@tabler/icons-react";
+
+import { clientApi } from "@homarr/api/client";
+import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
+import { useEditMode } from "@homarr/boards/edit-mode";
+import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
+import { useI18n, useScopedI18n } from "@homarr/translation/client";
+import { widgetCatalogIcons } from "@homarr/widgets/catalog";
+
+import type { EmptySection } from "~/app/[locale]/boards/_types";
+import { useBoardSelection } from "./board-selection-context";
+
+const MAX_AVATARS_DISPLAYED = 5;
+
+export const BoardSelectionToolbar = () => {
+ const [isEditMode] = useEditMode();
+ const { selectedItemIds, clearSelection, removeSelectedItems, moveSelectedItemsToSection } = useBoardSelection();
+ const board = useRequiredBoard();
+ const currentLayoutId = useCurrentLayout();
+ const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
+ const t = useI18n();
+ const tSelection = useScopedI18n("item.selection");
+
+ const selectedItems = board.items.filter((item) => selectedItemIds.has(item.id));
+ const hasAppWidgets = selectedItems.some((item) => item.kind === "app");
+ const { data: selectableApps } = clientApi.app.selectable.useQuery(undefined, {
+ enabled: hasAppWidgets,
+ });
+
+ if (!isEditMode || selectedItemIds.size === 0) return null;
+
+ const appMap = new Map((selectableApps ?? []).map((app) => [app.id, app]));
+
+ // Sort items so apps appear first
+ const sortedSelectedItems = selectedItems.toSorted((itemA, itemB) => {
+ if (itemA.kind === "app" && itemB.kind !== "app") return -1;
+ if (itemA.kind !== "app" && itemB.kind === "app") return 1;
+ return 0;
+ });
+
+ const getItemDetails = (item: (typeof selectedItems)[number]) => {
+ let displayName = item.advancedOptions.title?.trim();
+ let iconUrl: string | undefined = undefined;
+ const IconComponent = widgetCatalogIcons[item.kind] ?? IconCheck;
+
+ if (item.kind === "app") {
+ const appId = (item.options as { appId?: string })?.appId;
+ const app = appId ? appMap.get(appId) : undefined;
+ if (!displayName) {
+ displayName = app?.name || t("widget.app.name");
+ }
+ if (app?.iconUrl) {
+ iconUrl = app.iconUrl;
+ }
+ }
+
+ if (!displayName) {
+ displayName = t(`widget.${item.kind}.name`);
+ }
+
+ return { displayName, iconUrl, IconComponent, kind: item.kind };
+ };
+
+ const visibleItems = sortedSelectedItems.slice(0, MAX_AVATARS_DISPLAYED);
+ const overflowCount = sortedSelectedItems.length - visibleItems.length;
+
+ const mainCanvasSection = board.sections
+ .filter(
+ (section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
+ )
+ .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
+ .at(0);
+
+ const placementOptions = [
+ ...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
+ ...board.sections.flatMap((section) => {
+ if (section.kind !== "empty") return [];
+ const lane = getRootSectionLane(section.xOffset);
+ if (lane === "main" || !currentLayout || getBoardLaneColumnCount(currentLayout, lane) === 0) return [];
+ return [
+ {
+ value: section.id,
+ label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
+ },
+ ];
+ }),
+ ...board.sections
+ .filter((section) => section.kind === "container")
+ .map((section) => ({
+ value: section.id,
+ label: section.options.title.trim() || t("section.container.untitled"),
+ })),
+ ];
+
+ return (
+
+
+ {/* Avatar Group with HoverCard Popover showing all items */}
+
+
+
+
+ {visibleItems.map((item) => {
+ const { displayName, iconUrl, IconComponent } = getItemDetails(item);
+ return (
+
+ {!iconUrl && }
+
+ );
+ })}
+ {overflowCount > 0 && (
+
+ +{overflowCount}
+
+ )}
+
+
+
+
+
+
+
+ {tSelection("selected", { count: sortedSelectedItems.length })}
+
+
+
+
+ {sortedSelectedItems.map((item) => {
+ const { displayName, iconUrl, IconComponent } = getItemDetails(item);
+ return (
+
+
+ {!iconUrl && }
+
+
+ {displayName}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ {t("app.action.select.multiSelectTip")}
+
+
+
+
+ {/* Actions */}
+
+ {placementOptions.length > 1 && (
+
+
+ }>
+ {tSelection("move")}
+
+
+
+ {tSelection("moveTo")}
+ {placementOptions.map((opt) => (
+ moveSelectedItemsToSection(opt.value)}>
+ {opt.label}
+
+ ))}
+
+
+ )}
+
+ }
+ onClick={removeSelectedItems}
+ >
+ {tSelection("delete")}
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/components/board/selection/board-selection.spec.tsx b/apps/nextjs/src/components/board/selection/board-selection.spec.tsx
new file mode 100644
index 000000000..21fcc13ba
--- /dev/null
+++ b/apps/nextjs/src/components/board/selection/board-selection.spec.tsx
@@ -0,0 +1,251 @@
+// @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 { Board } from "~/app/[locale]/boards/_types";
+import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
+import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
+import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
+import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
+import { LayoutMockBuilder } from "~/components/board/items/actions/test/mocks/layout-mock";
+import { BoardSelectionProvider, useBoardSelection } from "./board-selection-context";
+
+vi.mock("@homarr/translation/client", () => ({
+ useI18n: () => (key: string) => key,
+ useScopedI18n: () => (key: string) => key,
+}));
+
+vi.mock("../audio/board-sounds", () => ({
+ playPopSound: vi.fn(),
+ playTrashSound: vi.fn(),
+}));
+
+let currentBoardState: Board;
+let mockUpdateBoard: (updater: (prev: Board) => Board) => void;
+
+vi.mock("@homarr/boards/context", () => ({
+ useRequiredBoard: () => currentBoardState,
+ useCurrentLayout: () => "layout-1",
+}));
+
+vi.mock("@homarr/boards/edit-mode", () => ({
+ useEditMode: () => [true, vi.fn()],
+}));
+
+vi.mock("@homarr/boards/updater", () => ({
+ usePersistBoard: () => ({
+ updateAndPersistBoard: (updater: (prev: Board) => Board) => {
+ mockUpdateBoard(updater);
+ },
+ }),
+}));
+
+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.appendChild(host);
+ root = createRoot(host);
+});
+
+afterEach(() => {
+ act(() => {
+ root.unmount();
+ });
+ host.remove();
+});
+
+const SelectionConsumer = ({ onReady }: { onReady: (ctx: ReturnType) => void }) => {
+ const selection = useBoardSelection();
+ onReady(selection);
+ return Selection Ready
;
+};
+
+describe("BoardSelectionContext", () => {
+ it("toggles item selection and clears on clearSelection", async () => {
+ const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
+ const item1 = new ItemMockBuilder({ id: "item-1" }).addLayout({ layoutId: "layout-1" }).build();
+ currentBoardState = new BoardMockBuilder().addLayout(layout).addItem(item1).build();
+ mockUpdateBoard = vi.fn();
+
+ let selectionContext!: ReturnType;
+
+ await act(async () => {
+ root.render(
+
+
+ {
+ selectionContext = ctx;
+ }}
+ />
+
+ ,
+ );
+ });
+
+ expect(selectionContext.isSelected("item-1")).toBe(false);
+
+ await act(async () => {
+ selectionContext.toggleSelectItem("item-1");
+ });
+ expect(selectionContext.isSelected("item-1")).toBe(true);
+
+ await act(async () => {
+ selectionContext.clearSelection();
+ });
+ expect(selectionContext.isSelected("item-1")).toBe(false);
+ });
+
+ it("moves selected items to the first available positions in a container section", async () => {
+ const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
+ const emptyRoot = new EmptySectionMockBuilder({ id: "empty-root", xOffset: 0 }).build();
+ const container = new ContainerSectionMockBuilder({ id: "container-1" })
+ .addLayout({ layoutId: "layout-1", parentSectionId: "empty-root", width: 4, height: 4 })
+ .build();
+
+ // Existing item inside the container at (0, 0) with 2x2
+ const existingContainerItem = new ItemMockBuilder({ id: "existing-1" })
+ .addLayout({ layoutId: "layout-1", sectionId: "container-1", xOffset: 0, yOffset: 0, width: 2, height: 2 })
+ .build();
+
+ // Two items to move from emptyRoot
+ const itemA = new ItemMockBuilder({ id: "item-a" })
+ .addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 5, yOffset: 5, width: 2, height: 1 })
+ .build();
+ const itemB = new ItemMockBuilder({ id: "item-b" })
+ .addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 8, yOffset: 8, width: 2, height: 1 })
+ .build();
+
+ currentBoardState = new BoardMockBuilder()
+ .addLayout(layout)
+ .addSection(emptyRoot)
+ .addSection(container)
+ .addItem(existingContainerItem)
+ .addItem(itemA)
+ .addItem(itemB)
+ .build();
+
+ let updatedBoard: Board = currentBoardState;
+ mockUpdateBoard = vi.fn((updater) => {
+ updatedBoard = updater(currentBoardState);
+ currentBoardState = updatedBoard;
+ });
+
+ let selectionContext!: ReturnType;
+
+ await act(async () => {
+ root.render(
+
+
+ {
+ selectionContext = ctx;
+ }}
+ />
+
+ ,
+ );
+ });
+
+ await act(async () => {
+ selectionContext.toggleSelectItem("item-a");
+ selectionContext.toggleSelectItem("item-b");
+ });
+
+ await act(async () => {
+ selectionContext.moveSelectedItemsToSection("container-1");
+ });
+
+ const movedA = updatedBoard.items.find((i) => i.id === "item-a")?.layouts.find((l) => l.layoutId === "layout-1");
+ const movedB = updatedBoard.items.find((i) => i.id === "item-b")?.layouts.find((l) => l.layoutId === "layout-1");
+
+ expect(movedA?.sectionId).toBe("container-1");
+ expect(movedB?.sectionId).toBe("container-1");
+
+ // Container width is 4. Existing item at (0, 0) occupies (0,0)-(1,1).
+ // itemA (2x1) is placed at first empty spot: (2, 0)
+ expect(movedA?.xOffset).toBe(2);
+ expect(movedA?.yOffset).toBe(0);
+
+ // itemB (2x1) is placed at next first empty spot: (2, 1)
+ expect(movedB?.xOffset).toBe(2);
+ expect(movedB?.yOffset).toBe(1);
+ });
+
+ it("keeps the entire selection in place when the destination cannot fit every item", async () => {
+ const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
+ const emptyRoot = new EmptySectionMockBuilder({ id: "empty-root", xOffset: 0 }).build();
+ const container = new ContainerSectionMockBuilder({ id: "container-1" })
+ .addLayout({ layoutId: "layout-1", parentSectionId: "empty-root", width: 2, height: 1 })
+ .build();
+ const existingContainerItem = new ItemMockBuilder({ id: "existing-1" })
+ .addLayout({ layoutId: "layout-1", sectionId: "container-1", xOffset: 0, yOffset: 0, width: 1, height: 1 })
+ .build();
+ const itemA = new ItemMockBuilder({ id: "item-a" })
+ .addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 0, yOffset: 0, width: 1, height: 1 })
+ .build();
+ const itemB = new ItemMockBuilder({ id: "item-b" })
+ .addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 1, yOffset: 0, width: 1, height: 1 })
+ .build();
+
+ currentBoardState = new BoardMockBuilder()
+ .addLayout(layout)
+ .addSection(emptyRoot)
+ .addSection(container)
+ .addItem(existingContainerItem)
+ .addItem(itemA)
+ .addItem(itemB)
+ .build();
+
+ let updatedBoard = currentBoardState;
+ mockUpdateBoard = vi.fn((updater) => {
+ updatedBoard = updater(currentBoardState);
+ currentBoardState = updatedBoard;
+ });
+
+ let selectionContext!: ReturnType;
+ await act(async () => {
+ root.render(
+
+
+ {
+ selectionContext = ctx;
+ }}
+ />
+
+ ,
+ );
+ });
+
+ await act(async () => {
+ selectionContext.toggleSelectItem("item-a");
+ selectionContext.toggleSelectItem("item-b");
+ });
+ await act(async () => {
+ selectionContext.moveSelectedItemsToSection("container-1");
+ });
+
+ expect(updatedBoard.items.find((item) => item.id === "item-a")?.layouts[0]?.sectionId).toBe("empty-root");
+ expect(updatedBoard.items.find((item) => item.id === "item-b")?.layouts[0]?.sectionId).toBe("empty-root");
+ expect(selectionContext.isSelected("item-a")).toBe(true);
+ expect(selectionContext.isSelected("item-b")).toBe(true);
+ });
+});
diff --git a/apps/nextjs/src/components/create/setup-analytics.ts b/apps/nextjs/src/components/create/setup-analytics.ts
index 5a7e12815..59250ac03 100644
--- a/apps/nextjs/src/components/create/setup-analytics.ts
+++ b/apps/nextjs/src/components/create/setup-analytics.ts
@@ -37,7 +37,7 @@ export const useSetupAnalytics = () => {
onError() {
showErrorNotification({
title: t("common.error"),
- message: t("universalCreate.analyticsError"),
+ message: t("common.analyticsError"),
});
},
});
diff --git a/apps/nextjs/src/components/create/universal-create-actions.spec.ts b/apps/nextjs/src/components/create/universal-create-actions.spec.ts
deleted file mode 100644
index 60e323a4b..000000000
--- a/apps/nextjs/src/components/create/universal-create-actions.spec.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { describe, expect, test } from "vitest";
-
-import type { RankedUniversalCreateAction } from "./universal-create-actions";
-import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
-
-describe("getUniversalCreateActionDefinitions", () => {
- test("only exposes actions allowed without a board context", () => {
- const actions = getUniversalCreateActionDefinitions({
- hasBoardContext: false,
- permissions: ["board-create", "integration-create"],
- });
-
- expect(actions.map(({ key }) => key)).toEqual(["integration", "board"]);
- });
-
- test("places globally-created apps in the library group", () => {
- const actions = getUniversalCreateActionDefinitions({ hasBoardContext: false, permissions: ["app-create"] });
-
- expect(actions).toEqual([expect.objectContaining({ key: "app", group: "library" })]);
- });
-
- test("keeps board content actions available without global create permissions", () => {
- const actions = getUniversalCreateActionDefinitions({ hasBoardContext: true, permissions: [] });
-
- expect(actions.map(({ key }) => key)).toEqual(["widget", "app", "container"]);
- });
-
- test("exposes the complete surface to administrators on a board", () => {
- const actions = getUniversalCreateActionDefinitions({
- hasBoardContext: true,
- permissions: ["admin", "app-create", "integration-create", "board-create"],
- });
-
- expect(actions.map(({ key }) => key)).toEqual([
- "widget",
- "app",
- "integration",
- "container",
- "board",
- "workshop",
- "customWidget",
- ]);
- });
-});
-
-describe("filterAndRankUniversalCreateActions", () => {
- const actions = [
- { key: "widget", group: "currentBoard", priority: 100, name: "Add widget", description: "Add content" },
- {
- key: "integration",
- group: "library",
- priority: 80,
- name: "Connect service",
- description: "Create an integration",
- keywords: ["provider"],
- },
- { key: "board", group: "boards", priority: 60, name: "Create board", description: "Start a dashboard" },
- ] satisfies RankedUniversalCreateAction[];
-
- test("uses product priority when no search is entered", () => {
- expect(filterAndRankUniversalCreateActions(actions, "").map(({ key }) => key)).toEqual([
- "widget",
- "integration",
- "board",
- ]);
- });
-
- test("ranks a name prefix ahead of a description or keyword match", () => {
- const results = filterAndRankUniversalCreateActions(actions, "create");
-
- expect(results.map(({ key }) => key)).toEqual(["board", "integration"]);
- });
-
- test("matches keywords and removes unrelated actions", () => {
- expect(filterAndRankUniversalCreateActions(actions, "provider").map(({ key }) => key)).toEqual(["integration"]);
- });
-});
diff --git a/apps/nextjs/src/components/create/universal-create-actions.ts b/apps/nextjs/src/components/create/universal-create-actions.ts
deleted file mode 100644
index 7f9d37643..000000000
--- a/apps/nextjs/src/components/create/universal-create-actions.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import type { GroupPermissionKey } from "@homarr/definitions";
-
-export const universalCreateActionKeys = [
- "widget",
- "app",
- "integration",
- "container",
- "board",
- "workshop",
- "customWidget",
-] as const;
-
-export type UniversalCreateActionKey = (typeof universalCreateActionKeys)[number];
-export type UniversalCreateActionGroup = "currentBoard" | "library" | "boards";
-
-export interface UniversalCreateContext {
- hasBoardContext: boolean;
- permissions: readonly GroupPermissionKey[];
-}
-
-interface UniversalCreateActionDefinition {
- key: UniversalCreateActionKey;
- group: UniversalCreateActionGroup;
- priority: number;
- isVisible: (context: UniversalCreateContext) => boolean;
-}
-
-const hasPermission = (context: UniversalCreateContext, permission: GroupPermissionKey) =>
- context.permissions.includes(permission);
-
-const definitions: readonly UniversalCreateActionDefinition[] = [
- {
- key: "widget",
- group: "currentBoard",
- priority: 100,
- isVisible: ({ hasBoardContext }) => hasBoardContext,
- },
- {
- key: "app",
- group: "currentBoard",
- priority: 90,
- isVisible: (context) => context.hasBoardContext || hasPermission(context, "app-create"),
- },
- {
- key: "integration",
- group: "library",
- priority: 80,
- isVisible: (context) => hasPermission(context, "integration-create"),
- },
- {
- key: "container",
- group: "currentBoard",
- priority: 70,
- isVisible: ({ hasBoardContext }) => hasBoardContext,
- },
- {
- key: "board",
- group: "boards",
- priority: 60,
- isVisible: (context) => hasPermission(context, "board-create"),
- },
- {
- key: "workshop",
- group: "library",
- priority: 40,
- isVisible: (context) => hasPermission(context, "admin"),
- },
- {
- key: "customWidget",
- group: "library",
- priority: 30,
- isVisible: (context) => hasPermission(context, "admin"),
- },
-];
-
-export interface RankedUniversalCreateAction {
- key: UniversalCreateActionKey;
- group: UniversalCreateActionGroup;
- priority: number;
- name: string;
- description: string;
- keywords?: readonly string[];
-}
-
-export const getUniversalCreateActionDefinitions = (context: UniversalCreateContext) =>
- definitions
- .filter((definition) => definition.isVisible(context))
- .map((definition) =>
- definition.key === "app" && !context.hasBoardContext ? { ...definition, group: "library" as const } : definition,
- );
-
-const normalize = (value: string) => value.trim().toLocaleLowerCase();
-
-export const filterAndRankUniversalCreateActions = (
- actions: readonly TAction[],
- query: string,
-) => {
- const normalizedQuery = normalize(query);
-
- return actions
- .map((action) => {
- const name = normalize(action.name);
- const searchableText = normalize([action.name, action.description, ...(action.keywords ?? [])].join(" "));
- const relevance =
- normalizedQuery.length === 0
- ? 0
- : name === normalizedQuery
- ? 300
- : name.startsWith(normalizedQuery)
- ? 200
- : searchableText.includes(normalizedQuery)
- ? 100
- : -1;
-
- return { action, relevance };
- })
- .filter(({ relevance }) => relevance >= 0)
- .toSorted((left, right) => right.relevance - left.relevance || right.action.priority - left.action.priority)
- .map(({ action }) => action);
-};
diff --git a/apps/nextjs/src/components/create/universal-create-modal.module.css b/apps/nextjs/src/components/create/universal-create-modal.module.css
deleted file mode 100644
index 38e8cf86a..000000000
--- a/apps/nextjs/src/components/create/universal-create-modal.module.css
+++ /dev/null
@@ -1,21 +0,0 @@
-.action {
- width: 100%;
- border-radius: var(--mantine-radius-md);
- padding: var(--mantine-spacing-sm);
- text-align: start;
-}
-
-@media (hover: hover) {
- .action:hover {
- background: var(--mantine-color-default-hover);
- }
-}
-
-.action:focus-visible {
- outline: 2px solid var(--mantine-primary-color-filled);
- outline-offset: 2px;
-}
-
-.actionIcon {
- flex: 0 0 auto;
-}
diff --git a/apps/nextjs/src/components/create/universal-create-modal.module.d.css.ts b/apps/nextjs/src/components/create/universal-create-modal.module.d.css.ts
deleted file mode 100644
index 5de57c8f7..000000000
--- a/apps/nextjs/src/components/create/universal-create-modal.module.d.css.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-declare const classes: {
- readonly action: string;
- readonly actionIcon: string;
-};
-export default classes;
diff --git a/apps/nextjs/src/components/create/universal-create-modal.tsx b/apps/nextjs/src/components/create/universal-create-modal.tsx
deleted file mode 100644
index a6676a227..000000000
--- a/apps/nextjs/src/components/create/universal-create-modal.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-"use client";
-
-import { useEffect, useRef } from "react";
-import { useRouter } from "next/navigation";
-import { Group, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
-import {
- IconApps,
- IconBox,
- IconCode,
- IconLayoutDashboard,
- IconLayoutGridAdd,
- IconPlugConnected,
- IconSearch,
-} from "@tabler/icons-react";
-
-import { useSession } from "@homarr/auth/client";
-import type { GroupPermissionKey } from "@homarr/definitions";
-import { createModal, useModalAction } from "@homarr/modals";
-import { AddBoardModal } from "@homarr/modals-collection";
-import { useScopedI18n } from "@homarr/translation/client";
-import type { TablerIcon } from "@homarr/ui";
-
-import classes from "./universal-create-modal.module.css";
-import type {
- RankedUniversalCreateAction,
- UniversalCreateActionGroup,
- UniversalCreateActionKey,
-} from "./universal-create-actions";
-import { getUniversalCreateActionDefinitions } from "./universal-create-actions";
-import type { SetupMetricEntryPoint } from "./setup-analytics";
-import { useSetupAnalytics } from "./setup-analytics";
-
-type BoardActionKey = "widget" | "app" | "integration" | "container";
-
-export interface UniversalCreateModalProps {
- boardActions?: Partial void>>;
- entryPoint?: SetupMetricEntryPoint;
-}
-
-const actionIcons = {
- widget: IconApps,
- app: IconBox,
- integration: IconPlugConnected,
- container: IconLayoutGridAdd,
- board: IconLayoutDashboard,
- workshop: IconSearch,
- customWidget: IconCode,
-} satisfies Record;
-
-export const UniversalCreateModal = createModal(({ actions, innerProps }) => {
- const { data: session } = useSession();
- const router = useRouter();
- const t = useScopedI18n("universalCreate");
- const { openModal: openAddBoardModal } = useModalAction(AddBoardModal);
- const trackedOpen = useRef(false);
- const boardActions = innerProps.boardActions;
- const entryPoint = innerProps.entryPoint ?? (boardActions ? "board" : "header");
- const trackSetup = useSetupAnalytics();
- const permissions = (session?.user.permissions ?? []) as GroupPermissionKey[];
- const definitions = getUniversalCreateActionDefinitions({
- hasBoardContext: boardActions !== undefined,
- permissions,
- });
-
- useEffect(() => {
- if (trackedOpen.current) return;
- trackedOpen.current = true;
- trackSetup("surface-opened", {
- entryPoint,
- hasBoardContext: boardActions !== undefined,
- });
- }, [boardActions, entryPoint, trackSetup]);
-
- const invokeAction = (key: UniversalCreateActionKey) => {
- const boardAction = key in (boardActions ?? {}) ? boardActions?.[key as BoardActionKey] : undefined;
- actions.closeModal();
- trackSetup("intent-selected", {
- entryPoint,
- intent: key,
- outcome: "continued",
- hasBoardContext: boardActions !== undefined,
- });
-
- if (boardAction) {
- boardAction();
- return;
- }
-
- switch (key) {
- case "app":
- router.push("/manage/apps/new");
- break;
- case "integration":
- router.push("/manage/integrations/new");
- break;
- case "board":
- openAddBoardModal(undefined);
- break;
- case "workshop":
- router.push("/manage/workshop");
- break;
- case "customWidget":
- router.push("/manage/custom-widgets/new");
- break;
- case "widget":
- case "container":
- break;
- }
- };
-
- const resolvedActions = definitions.map(
- (definition) =>
- ({
- ...definition,
- name: t(`action.${definition.key}.label`),
- description: t(`action.${definition.key}.description`),
- keywords: t(`action.${definition.key}.keywords`).split(" "),
- }) satisfies RankedUniversalCreateAction,
- );
- const orderedActions = resolvedActions.toSorted((left, right) => right.priority - left.priority);
- const groups = ["currentBoard", "library", "boards"] satisfies UniversalCreateActionGroup[];
-
- return (
-
- {groups.map((group) => {
- const groupActions = orderedActions.filter((action) => action.group === group);
- if (groupActions.length === 0) return null;
-
- return (
-
-
- {t(`group.${group}`)}
-
- {groupActions.map((action) => {
- const Icon = actionIcons[action.key];
- return (
- invokeAction(action.key)}>
-
-
-
-
-
-
- {action.name}
-
-
- {action.description}
-
-
-
-
- );
- })}
-
- );
- })}
-
- );
-}).withOptions({
- defaultTitle: (t) => t("universalCreate.title"),
- size: "lg",
-});
diff --git a/apps/nextjs/src/components/create/universal-create-spotlight.tsx b/apps/nextjs/src/components/create/universal-create-spotlight.tsx
deleted file mode 100644
index 35832a64e..000000000
--- a/apps/nextjs/src/components/create/universal-create-spotlight.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-"use client";
-
-import { useMemo } from "react";
-import { IconPlus } from "@tabler/icons-react";
-
-import { useModalAction } from "@homarr/modals";
-import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
-import { useScopedI18n } from "@homarr/translation/client";
-
-import { UniversalCreateModal } from "./universal-create-modal";
-
-/** Keeps Universal Create available from ordinary Spotlight search, without requiring command mode. */
-export const UniversalCreateSpotlightResult = () => {
- const t = useScopedI18n("universalCreate");
- const { openModal } = useModalAction(UniversalCreateModal);
- const item = useMemo(
- () => ({
- id: "universal-create",
- name: t("title"),
- description: t("trigger.description"),
- icon: IconPlus,
- alwaysVisible: true,
- interaction: () => ({
- type: "javaScript" as const,
- onSelect: () => {
- openModal({ entryPoint: "spotlight" });
- },
- }),
- }),
- [openModal, t],
- );
-
- useRegisterSpotlightContextResults("universal-create", [item], [item]);
- return null;
-};
diff --git a/apps/nextjs/src/components/create/universal-create-trigger.tsx b/apps/nextjs/src/components/create/universal-create-trigger.tsx
deleted file mode 100644
index 3bf2d4269..000000000
--- a/apps/nextjs/src/components/create/universal-create-trigger.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-"use client";
-
-import { useEffect, useRef } from "react";
-import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { IconPlus } from "@tabler/icons-react";
-
-import { useModalAction } from "@homarr/modals";
-import { useScopedI18n } from "@homarr/translation/client";
-
-import { HeaderButton } from "~/components/layout/header/button";
-import { UniversalCreateModal } from "./universal-create-modal";
-
-export const UniversalCreateHeaderAction = () => {
- const t = useScopedI18n("universalCreate");
- const { openModal } = useModalAction(UniversalCreateModal);
-
- return (
- openModal({ entryPoint: "header" })} aria-label={t("trigger.label")}>
-
-
- );
-};
-
-export const UniversalCreateQueryGate = () => {
- const pathname = usePathname();
- const router = useRouter();
- const searchParams = useSearchParams();
- const { openModal } = useModalAction(UniversalCreateModal);
- const handled = useRef(false);
- const shouldOpen = searchParams.get("create") === "true";
-
- useEffect(() => {
- if (!shouldOpen) {
- handled.current = false;
- return;
- }
- if (handled.current) return;
-
- handled.current = true;
- openModal({ entryPoint: "spotlight" });
-
- const nextSearchParams = new URLSearchParams(searchParams.toString());
- nextSearchParams.delete("create");
- const query = nextSearchParams.toString();
- router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
- }, [openModal, pathname, router, searchParams, shouldOpen]);
-
- return null;
-};
diff --git a/apps/nextjs/src/components/integration/integration-select-modal.tsx b/apps/nextjs/src/components/integration/integration-select-modal.tsx
index 55105c538..3bb68169d 100644
--- a/apps/nextjs/src/components/integration/integration-select-modal.tsx
+++ b/apps/nextjs/src/components/integration/integration-select-modal.tsx
@@ -1,9 +1,10 @@
"use client";
-import { useState } from "react";
-import { Group, ScrollArea, Title, UnstyledButton } from "@mantine/core";
+import { useEffect, useState } from "react";
+import { Alert, Button, Group, ScrollArea, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import { IconArrowLeft, IconX } from "@tabler/icons-react";
+import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
@@ -27,9 +28,28 @@ interface IntegrationSelectModalProps {
export const IntegrationSelectModal = createModal(({ actions, innerProps }) => {
const t = useI18n();
- const [step, setStep] = useState<"select" | "form">(innerProps.initialKind ? "form" : "select");
- const [selectedKind, setSelectedKind] = useState(innerProps.initialKind ?? null);
+ const directKind =
+ innerProps.initialKind ?? (innerProps.allowedKinds?.length === 1 ? innerProps.allowedKinds[0] : null);
+ const isDirectForm = Boolean(directKind);
+ const [step, setStep] = useState<"select" | "form">(isDirectForm ? "form" : "select");
+ const [selectedKind, setSelectedKind] = useState(directKind ?? null);
const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
+ const { data: integrationData, isError: isIntegrationDataError, refetch } = clientApi.integration.all.useQuery();
+
+ useEffect(() => {
+ if (step === "form" && !isDirectForm) {
+ actions.setCloseInterceptor?.(() => {
+ setStep("select");
+ setSelectedKind(null);
+ return false;
+ });
+ } else {
+ actions.setCloseInterceptor?.(null);
+ }
+ return () => {
+ actions.setCloseInterceptor?.(null);
+ };
+ }, [step, isDirectForm, actions]);
const handleSelect = (kind: IntegrationKind) => {
setSelectedKind(kind);
@@ -40,7 +60,7 @@ export const IntegrationSelectModal = createModal((
setStep("select");
setSelectedKind(null);
};
- const handleFormBack = innerProps.initialKind ? actions.closeModal : handleBack;
+ const handleFormBack = isDirectForm ? actions.closeModal : handleBack;
const handleSuccess = (result?: CreatedIntegrationResult) => {
actions.closeModal();
@@ -52,12 +72,8 @@ export const IntegrationSelectModal = createModal((
};
if (step === "form" && selectedKind) {
- let HeaderIcon = IconArrowLeft;
- let headerLabel = t("common.action.previous");
- if (innerProps.initialKind) {
- HeaderIcon = IconX;
- headerLabel = t("common.action.close");
- }
+ const HeaderIcon = isDirectForm ? IconX : IconArrowLeft;
+ const headerLabel = isDirectForm ? t("common.action.close") : t("common.action.previous");
return (
@@ -79,11 +95,25 @@ export const IntegrationSelectModal = createModal((
);
}
+ if (isIntegrationDataError) {
+ return (
+
+
+ {t("integration.grid.loadError")}
+ void refetch()}>
+ {t("common.action.tryAgain")}
+
+
+
+ );
+ }
+
return (
);
}).withOptions({
diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx
index 74e52d5b8..2ad377576 100644
--- a/apps/nextjs/src/components/layout/header.tsx
+++ b/apps/nextjs/src/components/layout/header.tsx
@@ -3,8 +3,6 @@ import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
import { Link } from "@homarr/ui";
-import { UniversalCreateHeaderAction, UniversalCreateQueryGate } from "~/components/create/universal-create-trigger";
-import { UniversalCreateSpotlightResult } from "~/components/create/universal-create-spotlight";
import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target";
@@ -38,7 +36,6 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
- {hasNavigation && }
{actions}
-
-
);
diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts
index 6af7bf14b..5790b89ac 100644
--- a/packages/definitions/src/docs/homarr-docs-sitemap.ts
+++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts
@@ -336,7 +336,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/whats-up-docker"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
- | "/docs/widgets/today"
| "/docs/widgets/air-quality"
| "/docs/widgets/countdown"
| "/docs/widgets/timer"
diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts
index 2504dda24..466d36369 100644
--- a/packages/definitions/src/docs/widget-doc-slugs.ts
+++ b/packages/definitions/src/docs/widget-doc-slugs.ts
@@ -3,7 +3,6 @@ import type { WidgetKind } from "../widget";
export const widgetDocSlugs: Record = {
clock: "clock",
weather: "weather",
- today: "today",
airQuality: "air-quality",
countdown: "countdown",
timer: "timer",
diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts
index f3223a186..5279df2e0 100644
--- a/packages/definitions/src/widget.ts
+++ b/packages/definitions/src/widget.ts
@@ -1,7 +1,6 @@
export const widgetKinds = [
"clock",
"weather",
- "today",
"airQuality",
"countdown",
"timer",
@@ -63,7 +62,6 @@ export const widgetKinds = [
export type WidgetKind = (typeof widgetKinds)[number];
export const widgetDefaultSizes: Partial> = {
- today: { width: 2, height: 1 },
airQuality: { width: 2, height: 1 },
countdown: { width: 2, height: 1 },
timer: { width: 2, height: 1 },
diff --git a/packages/modals-collection/src/apps/app-select-modal.spec.tsx b/packages/modals-collection/src/apps/app-select-modal.spec.tsx
new file mode 100644
index 000000000..797910440
--- /dev/null
+++ b/packages/modals-collection/src/apps/app-select-modal.spec.tsx
@@ -0,0 +1,438 @@
+// @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 { AppSelectModal } from "./app-select-modal";
+
+const AppSelectModalComponent = AppSelectModal.component;
+
+const sampleApps = [
+ {
+ id: "app-1",
+ name: "Sonarr",
+ description: "Manage TV series downloads",
+ iconUrl: "https://example.com/sonarr.png",
+ href: "http://sonarr.local",
+ },
+ {
+ id: "app-2",
+ name: "Radarr",
+ description: "Manage movie collections",
+ iconUrl: "https://example.com/radarr.png",
+ href: "http://radarr.local",
+ },
+ {
+ id: "app-3",
+ name: "Plex",
+ description: "Stream your media collection",
+ iconUrl: "https://example.com/plex.png",
+ href: "http://plex.local",
+ },
+];
+
+const mockUseQuery = vi.fn(() => ({
+ data: sampleApps,
+ isPending: false,
+}));
+
+const mockOpenQuickAddAppModal = vi.fn();
+
+vi.mock("@homarr/api/client", () => ({
+ clientApi: {
+ app: {
+ selectable: {
+ useQuery: () => mockUseQuery(),
+ },
+ },
+ },
+}));
+
+vi.mock("@homarr/modals", async () => {
+ const actual = await vi.importActual>("@homarr/modals");
+ return {
+ ...actual,
+ useModalAction: () => ({
+ openModal: mockOpenQuickAddAppModal,
+ }),
+ };
+});
+
+vi.mock("@homarr/translation/client", () => ({
+ useI18n: () => (key: string, values?: { count?: number }) => {
+ const count = values?.count ?? 0;
+ const translations: Record = {
+ "app.action.select.search": "Search apps",
+ "app.action.select.title": "Select application",
+ "app.action.select.selected": "Selected",
+ "app.action.select.toggle": "Click to select",
+ "app.action.select.noResults": "No apps found",
+ "app.action.select.multiSelectTip": "Hold Ctrl or Command to select multiple apps",
+ "app.action.select.selectedCount": `${count} selected`,
+ "app.action.select.appsSelected": `${count} ${count === 1 ? "app" : "apps"} selected`,
+ "app.action.select.customApplication": "Custom application",
+ "app.action.select.application": "Application",
+ "app.action.create.title": "Add custom app",
+ "app.action.create.description": "Configure custom URL and icon",
+ "common.action.discard": "Discard",
+ "common.action.add": "Add",
+ };
+ return translations[key] ?? key;
+ },
+}));
+
+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(),
+ }));
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ );
+ host = document.createElement("div");
+ document.body.append(host);
+ root = createRoot(host);
+ vi.clearAllMocks();
+ mockUseQuery.mockReturnValue({
+ data: sampleApps,
+ isPending: false,
+ });
+});
+
+afterEach(async () => {
+ await act(async () => root.unmount());
+ host.remove();
+});
+
+describe("AppSelectModal", () => {
+ describe("single selection mode", () => {
+ it("renders app cards with name, description, and icon", async () => {
+ const closeModal = vi.fn();
+ const onSelect = vi.fn();
+
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ expect(host.textContent).toContain("Sonarr");
+ expect(host.textContent).toContain("Radarr");
+ expect(host.textContent).toContain("Plex");
+ expect(host.textContent).toContain("Manage TV series downloads");
+ });
+
+ it("selects an app and closes modal when card is clicked", async () => {
+ const closeModal = vi.fn();
+ const onSelect = vi.fn();
+
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
+ expect(sonarrCard).toBeDefined();
+
+ await act(async () => {
+ sonarrCard?.click();
+ });
+
+ expect(onSelect).toHaveBeenCalledWith(sampleApps[0]);
+ expect(closeModal).toHaveBeenCalledOnce();
+ });
+ });
+
+ describe("multi selection mode", () => {
+ it("toggles multiple app selections and submits batch on button click", async () => {
+ const closeModal = vi.fn();
+ const onSelectMany = vi.fn();
+
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
+ const radarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Radarr"));
+
+ // Select Sonarr
+ await act(async () => {
+ sonarrCard?.click();
+ });
+
+ // Modal stays open, 1 app selected
+ expect(closeModal).not.toHaveBeenCalled();
+ expect(host.textContent).toContain("1 app selected");
+
+ // Select Radarr
+ await act(async () => {
+ radarrCard?.click();
+ });
+
+ expect(host.textContent).toContain("2 apps selected");
+
+ // Submit multi select
+ const submitBtn = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Add (2)"));
+ expect(submitBtn).toBeDefined();
+
+ await act(async () => {
+ submitBtn?.click();
+ });
+
+ expect(onSelectMany).toHaveBeenCalledWith([sampleApps[0], sampleApps[1]]);
+ expect(closeModal).toHaveBeenCalledOnce();
+ });
+
+ it("unselects an app when clicked a second time", async () => {
+ const closeModal = vi.fn();
+ const onSelectMany = vi.fn();
+
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
+
+ // Select Sonarr
+ await act(async () => {
+ sonarrCard?.click();
+ });
+ expect(host.textContent).toContain("1 app selected");
+
+ // Unselect Sonarr
+ await act(async () => {
+ sonarrCard?.click();
+ });
+ expect(host.textContent).not.toContain("1 app selected");
+ });
+
+ it("clears selection when Discard button is clicked", async () => {
+ const closeModal = vi.fn();
+ const onSelectMany = vi.fn();
+
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
+ await act(async () => {
+ sonarrCard?.click();
+ });
+ expect(host.textContent).toContain("1 app selected");
+
+ const discardBtn = Array.from(host.querySelectorAll("button")).find((btn) =>
+ btn.textContent?.includes("Discard"),
+ );
+ await act(async () => {
+ discardBtn?.click();
+ });
+
+ expect(host.textContent).not.toContain("app selected");
+ });
+ });
+
+ describe("search and filtering", () => {
+ it("filters apps in real-time by search query (case-insensitive)", async () => {
+ const closeModal = vi.fn();
+ const onSelect = vi.fn();
+
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const searchInput = host.querySelector("input");
+ expect(searchInput).not.toBeNull();
+
+ // Type "plex"
+ await act(async () => {
+ if (searchInput) {
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ "value",
+ )?.set;
+ nativeInputValueSetter?.call(searchInput, "plex");
+ searchInput.dispatchEvent(new Event("input", { bubbles: true }));
+ searchInput.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ });
+
+ expect(host.textContent).toContain("Plex");
+ expect(host.textContent).not.toContain("Sonarr");
+ expect(host.textContent).not.toContain("Radarr");
+ });
+
+ it("selects the single matching app when Enter key is pressed in search input", async () => {
+ const closeModal = vi.fn();
+ const onSelect = vi.fn();
+
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const searchInput = host.querySelector("input");
+ await act(async () => {
+ if (searchInput) {
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ "value",
+ )?.set;
+ nativeInputValueSetter?.call(searchInput, "sonarr");
+ searchInput.dispatchEvent(new Event("input", { bubbles: true }));
+ searchInput.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ });
+
+ await act(async () => {
+ searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
+ });
+
+ expect(onSelect).toHaveBeenCalledWith(sampleApps[0]);
+ expect(closeModal).toHaveBeenCalledOnce();
+ });
+
+ it("displays empty state message when search yields no results", async () => {
+ const closeModal = vi.fn();
+ const onSelect = vi.fn();
+
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const searchInput = host.querySelector("input");
+ await act(async () => {
+ if (searchInput) {
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ "value",
+ )?.set;
+ nativeInputValueSetter?.call(searchInput, "non-matching-query");
+ searchInput.dispatchEvent(new Event("input", { bubbles: true }));
+ searchInput.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ });
+
+ expect(host.textContent).toContain("No apps found");
+ });
+ });
+
+ describe("custom app creation card", () => {
+ it("renders custom app creation card when withCreate is true", async () => {
+ const closeModal = vi.fn();
+ const onSelect = vi.fn();
+
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ expect(host.textContent).toContain("Add custom app");
+ expect(host.textContent).toContain("Configure custom URL and icon");
+ });
+
+ it("omits custom app creation card when withCreate is false", async () => {
+ const closeModal = vi.fn();
+ const onSelect = vi.fn();
+
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ expect(host.textContent).not.toContain("Add custom app");
+ });
+
+ it("opens QuickAddAppModal when custom app creation card is clicked in single select mode", async () => {
+ const closeModal = vi.fn();
+ const onSelect = vi.fn();
+
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const createCard = Array.from(host.querySelectorAll("button")).find((btn) =>
+ btn.textContent?.includes("Add custom app"),
+ );
+ expect(createCard).toBeDefined();
+
+ await act(async () => {
+ createCard?.click();
+ });
+
+ expect(mockOpenQuickAddAppModal).toHaveBeenCalledOnce();
+
+ // Trigger the onClose callback passed to QuickAddAppModal
+ const options = mockOpenQuickAddAppModal.mock.calls[0]?.[0] as { onClose: (app: (typeof sampleApps)[0]) => void };
+ const newCustomApp = {
+ id: "custom-app-1",
+ name: "Custom Home Assistant",
+ description: "Local instance",
+ iconUrl: "https://example.com/ha.png",
+ href: "http://ha.local",
+ };
+
+ await act(async () => {
+ options.onClose(newCustomApp);
+ });
+
+ expect(onSelect).toHaveBeenCalledWith(newCustomApp);
+ expect(closeModal).toHaveBeenCalledOnce();
+ });
+ });
+});
diff --git a/packages/modals-collection/src/apps/app-select-modal.tsx b/packages/modals-collection/src/apps/app-select-modal.tsx
index e17558c84..ab2b5a63f 100644
--- a/packages/modals-collection/src/apps/app-select-modal.tsx
+++ b/packages/modals-collection/src/apps/app-select-modal.tsx
@@ -1,12 +1,25 @@
import { useMemo, useState } from "react";
-import { Button, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
-import { IconCheck, IconPlus, IconApps } from "@tabler/icons-react";
+import {
+ Badge,
+ Button,
+ Center,
+ Group,
+ Image,
+ Input,
+ Paper,
+ ScrollArea,
+ SimpleGrid,
+ Stack,
+ Text,
+ ThemeIcon,
+} from "@mantine/core";
+import { IconPlus, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
-import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
+import { selectGridCols, SelectableCard } from "@homarr/ui";
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
@@ -36,7 +49,15 @@ export const AppSelectModal = createModal(({ actions, inner
const selectedApps = useMemo(() => apps.filter((app) => selectedAppIds.has(app.id)), [apps, selectedAppIds]);
- const handleSelect = (app: SelectableApp) => {
+ const handleSelect = (app: SelectableApp, event?: React.MouseEvent) => {
+ const isModifierPressed = multiSelect && Boolean(event?.shiftKey || event?.ctrlKey || event?.metaKey);
+
+ if (innerProps.onSelect && !isModifierPressed && selectedAppIds.size === 0) {
+ innerProps.onSelect(app);
+ actions.closeModal();
+ return;
+ }
+
if (multiSelect) {
setSelectedAppIds((current) => {
const next = new Set(current);
@@ -44,22 +65,6 @@ export const AppSelectModal = createModal(({ actions, inner
else next.add(app.id);
return next;
});
- return;
- }
- innerProps.onSelect?.(app);
- actions.closeModal();
- };
-
- const handleSubmit = () => {
- if (multiSelect) {
- innerProps.onSelectMany?.(selectedApps);
- actions.closeModal();
- return;
- }
- const [first] = filteredApps;
- if (first) {
- innerProps.onSelect?.(first);
- actions.closeModal();
}
};
@@ -76,100 +81,145 @@ export const AppSelectModal = createModal(({ actions, inner
});
};
+ const handleMultiSubmit = () => {
+ innerProps.onSelectMany?.(selectedApps);
+ actions.closeModal();
+ };
+
return (
- <>
- {
- if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
- handleSelect(filteredApps[0]);
- }
- }}
- >
- {innerProps.withCreate && (
-
-
-
-
-
- {t("app.action.create.title")}
-
-
-
- {t("app.action.create.description")}
-
-
- {t("app.action.create.action")}
-
-
-
- )}
-
- {filteredApps.map((app) => (
-
+ {/* Top Search Input & Multi-Select Tip */}
+
+ setSearch(event.currentTarget.value)}
+ leftSection={ }
+ placeholder={`${t("app.action.select.search")}...`}
+ aria-label={t("app.action.select.search")}
+ data-autofocus
+ onKeyDown={(event) => {
+ if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
+ handleSelect(filteredApps[0]);
}
- selected={multiSelect ? selectedAppIds.has(app.id) : undefined}
- onSelect={() => handleSelect(app)}
- >
-
-
-
-
- {app.name}
-
- {multiSelect && selectedAppIds.has(app.id) && (
-
- )}
-
-
-
- {app.description ?? ""}
-
-
-
- {multiSelect
- ? selectedAppIds.has(app.id)
- ? t("app.action.select.selected")
- : t("app.action.select.toggle")
- : t("app.action.select.action", { app: app.name })}
-
-
-
- ))}
-
- {filteredApps.length === 0 && !isPending && (
-
- {t("app.action.select.noResults")}
-
+ }}
+ />
+ {multiSelect && (
+
+
+ {t("app.action.select.multiSelectTip")}
+
+ {selectedAppIds.size > 0 && (
+
+ {t("app.action.select.selectedCount", { count: selectedAppIds.size })}
+
+ )}
+
)}
-
- {multiSelect && (
-
- } disabled={selectedAppIds.size === 0} onClick={handleSubmit}>
- {t("app.action.select.multiple", { count: String(selectedAppIds.size) })}
-
-
+
+
+ {/* Scrollable Container with App Cards */}
+
+
+
+ {innerProps.withCreate && (
+
+
+
+ }
+ title={t("app.action.create.title")}
+ description={t("app.action.create.description")}
+ footerLeft={
+
+ {t("app.action.select.customApplication")}
+
+ }
+ />
+ )}
+
+ {filteredApps.map((app) => (
+
+ ))}
+
+
+ {filteredApps.length === 0 && !isPending && (
+
+ {t("app.action.select.noResults")}
+
+ )}
+
+
+
+ {/* Multi-Select Action Footer */}
+ {multiSelect && selectedApps.length > 0 && (
+
+
+
+ {t("app.action.select.appsSelected", { count: selectedApps.length })}
+
+
+ setSelectedAppIds(new Set())}>
+ {t("common.action.discard")}
+
+
+ {t("common.action.add")} ({selectedApps.length})
+
+
+
+
)}
- >
+
);
}).withOptions({
defaultTitle: (t) => t("app.action.select.title"),
size: modalSizeSelect,
});
+
+// =========================================================================
+// AppCard: Variant 3 (Dashboard Inset) with Medium Title & Large App Icon
+// =========================================================================
+const AppCard = ({
+ app,
+ isSelected,
+ multiSelect,
+ onSelect,
+}: {
+ app: SelectableApp;
+ isSelected: boolean;
+ multiSelect: boolean;
+ onSelect: (app: SelectableApp, event?: React.MouseEvent) => void;
+}) => {
+ const t = useI18n();
+
+ return (
+ onSelect(app, event)}
+ aria-label={app.name}
+ selected={isSelected}
+ icon={ }
+ title={app.name}
+ topRight={
+ isSelected ? (
+
+ {t("app.action.select.selected")}
+
+ ) : null
+ }
+ description={app.description}
+ footerLeft={
+
+ {multiSelect && isSelected ? t("app.action.select.selected") : t("app.action.select.application")}
+
+ }
+ />
+ );
+};
diff --git a/packages/modals/src/index.tsx b/packages/modals/src/index.tsx
index 884182462..fa25dd79b 100644
--- a/packages/modals/src/index.tsx
+++ b/packages/modals/src/index.tsx
@@ -102,6 +102,13 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
content: presentationClasses.inspectorContent,
});
+ const handleClose = useCallback(() => {
+ const canClose = modal.reference.onCloseRequest ? modal.reference.onCloseRequest() : true;
+ if (canClose) {
+ handleCloseModal();
+ }
+ }, [handleCloseModal, modal.reference]);
+
return (
{
{...otherModalProps}
title={translateIfNecessary(t, modal.props.defaultTitle)}
opened={opened}
- onClose={handleCloseModal}
+ onClose={handleClose}
>
{modal.reference.content}
@@ -144,7 +151,6 @@ export const useModalAction = (modal: TModal) =>
return {
openModal: (innerProps: inferInnerProps, options: OpenModalOptions | void) => {
// void actually is undefined
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
context.openModalInner({ modal, innerProps, options: options ?? {} });
},
};
diff --git a/packages/modals/src/reducer.tsx b/packages/modals/src/reducer.tsx
index ba22f7297..039f07e52 100644
--- a/packages/modals/src/reducer.tsx
+++ b/packages/modals/src/reducer.tsx
@@ -94,6 +94,8 @@ const getModal = (modal: ModalState) =>
const ModalContent = modal.modal.component;
const { innerProps, ...rest } = modal.props;
+ let closeInterceptor: (() => boolean) | null = null;
+
const FullModal = () => {
const context = useContext(ModalContext);
@@ -106,6 +108,9 @@ const getModal = (modal: ModalState) =>
innerProps={innerProps}
actions={{
closeModal: () => context.closeModal(modal.id),
+ setCloseInterceptor: (interceptor: (() => boolean) | null) => {
+ closeInterceptor = interceptor;
+ },
}}
/>
);
@@ -114,5 +119,11 @@ const getModal = (modal: ModalState) =>
return {
modalProps: rest,
content: ,
+ onCloseRequest: () => {
+ if (closeInterceptor) {
+ return closeInterceptor();
+ }
+ return true;
+ },
};
};
diff --git a/packages/modals/src/type.ts b/packages/modals/src/type.ts
index cd553ac1a..5faa421d2 100644
--- a/packages/modals/src/type.ts
+++ b/packages/modals/src/type.ts
@@ -5,10 +5,12 @@ import type { stringOrTranslation } from "@homarr/translation";
import type { ModalPresentation } from "./modal-presentation";
-export type ModalComponent = (props: {
- actions: { closeModal: () => void };
- innerProps: TInnerProps;
-}) => ReactNode;
+export interface ModalActions {
+ closeModal: () => void;
+ setCloseInterceptor?: (interceptor: (() => boolean) | null) => void;
+}
+
+export type ModalComponent = (props: { actions: ModalActions; innerProps: TInnerProps }) => ReactNode;
export type CreateModalOptions = Pick<
ModalOptions,
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 19e7c1ddd..492bd1880 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -236,61 +236,6 @@
}
}
},
- "universalCreate": {
- "title": "Create",
- "analyticsError": "Usage metrics could not be recorded. Your action was not affected.",
- "trigger": {
- "label": "Create something",
- "description": "Add content, connect a service, or create a board from one place."
- },
- "search": {
- "label": "Search create actions",
- "placeholder": "What do you want to create?",
- "noResults": "No matching actions"
- },
- "group": {
- "currentBoard": "Current board",
- "library": "Apps and services",
- "boards": "Boards"
- },
- "action": {
- "widget": {
- "label": "Add widget",
- "description": "Add live information or controls to this board.",
- "keywords": "item dashboard content"
- },
- "app": {
- "label": "Add or create app",
- "description": "Place an app shortcut on this board or create one for your library.",
- "keywords": "shortcut bookmark application"
- },
- "integration": {
- "label": "Connect service",
- "description": "Connect a service so widgets can use its data.",
- "keywords": "integration provider connection"
- },
- "container": {
- "label": "Add container",
- "description": "Group related board content into a resizable section.",
- "keywords": "section group layout"
- },
- "board": {
- "label": "Create board",
- "description": "Start a new dashboard and add its first widget.",
- "keywords": "dashboard page"
- },
- "workshop": {
- "label": "Browse Workshop",
- "description": "Discover community-built widgets and install them.",
- "keywords": "community discover install"
- },
- "customWidget": {
- "label": "Create custom widget",
- "description": "Build a widget tailored to your own workflow.",
- "keywords": "code jsx developer"
- }
- }
- },
"user": {
"title": "Users",
"name": "User",
@@ -876,7 +821,12 @@
"title": "Select an app to add to this board",
"toggle": "Click to select",
"selected": "Selected",
- "multiple": "Add {count}"
+ "multiple": "Add {count}",
+ "multiSelectTip": "Tip: Hold Ctrl / ⌘ to select multiple apps at once",
+ "selectedCount": "{count, plural, one {# selected} other {# selected}}",
+ "appsSelected": "{count, plural, one {# app selected} other {# apps selected}}",
+ "customApplication": "Custom application",
+ "application": "Application"
},
"create": {
"title": "Create new app",
@@ -1721,7 +1671,11 @@
"grid": {
"widgetCount": "{count} widgets",
"noWidgets": "No widgets",
- "detected": "Detected via Docker"
+ "detected": "Detected via Docker",
+ "connected": "{count, plural, one {# connected} other {# connected}}",
+ "tiedWidgets": "Tied widgets",
+ "more": "+{count} more",
+ "loadError": "Integrations could not be loaded. Try again before creating a new connection."
},
"category": {
"dnsHole": "DNS",
@@ -2135,6 +2089,7 @@
"success": "Success",
"beta": "Beta",
"error": "Error",
+ "analyticsError": "Setup analytics could not be recorded.",
"action": {
"add": "Add",
"apply": "Apply",
@@ -2810,6 +2765,8 @@
"needsSetup": "Needs setup",
"noConnectionRequired": "No connection required"
},
+ "customWidget": "Custom widget",
+ "standalone": "Standalone",
"missingIntegration": {
"title": "Connect a service first",
"noPermission": "This widget needs a compatible service connection. Ask an administrator to connect one, then try again."
@@ -2826,6 +2783,20 @@
}
}
},
+ "selection": {
+ "summary": "{count, plural, one {# selected item} other {# selected items}}",
+ "selected": "{count, plural, one {# selected} other {# selected}}",
+ "removed": "{count, plural, one {# item removed} other {# items removed}}",
+ "move": "Move",
+ "moveTo": "Move selected items",
+ "delete": "Delete",
+ "clear": "Clear selection",
+ "clearShortcut": "Clear selection (Escape)",
+ "saveErrorTitle": "Unable to update items",
+ "saveErrorMessage": "The changes could not be saved. The board was refreshed.",
+ "moveUnavailableTitle": "Unable to move selected items",
+ "moveUnavailableMessage": "The destination does not have enough room for the entire selection."
+ },
"moveResize": {
"title": "Move / resize item",
"entryLabel": "{name}, column {column}, row {row}",
@@ -3465,50 +3436,6 @@
"notificationsDenied": "Notifications are blocked in this browser."
}
},
- "today": {
- "name": "Today",
- "description": "Displays the current date and progress through the calendar.",
- "option": {
- "weekConvention": {
- "label": "Week numbering",
- "options": {
- "locale": "Use locale rules",
- "iso": "ISO 8601"
- }
- },
- "showWeekNumber": {
- "label": "Show week number"
- },
- "showDayOfYear": {
- "label": "Show day of year"
- },
- "showQuarter": {
- "label": "Show quarter"
- },
- "showYearProgress": {
- "label": "Show year progress"
- }
- },
- "weekNumber": "Week {week}",
- "dayOfYear": "Day {day} of {total}",
- "quarter": "Quarter {quarter}",
- "daysAfterToday": "{days, plural, =0 {Last day of the year} one {# day after today} other {# days after today}}",
- "weekProgressLabel": "Week progress",
- "monthProgressLabel": "Month progress",
- "yearProgressLabel": "Year progress",
- "yearProgress": "Year progress: {progress}%",
- "weekConvention": {
- "locale": "Week numbers follow your locale",
- "iso": "ISO 8601 week numbers"
- },
- "advanced": {
- "week": "Week number",
- "dayOfYear": "Day of year",
- "quarter": "Quarter",
- "quarterValue": "Q{quarter}",
- "daysRemaining": "Days after today"
- }
- },
"minecraftServerStatus": {
"name": "Minecraft Server Status",
"description": "Displays the status of a Minecraft server",
diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx
index a593e1975..e2f8d28dc 100644
--- a/packages/ui/src/components/index.tsx
+++ b/packages/ui/src/components/index.tsx
@@ -13,6 +13,13 @@ export { CustomPasswordInput } from "./password-input/password-input";
export { IntegrationAvatar } from "./integration-avatar";
export { BetaBadge } from "./beta-badge";
export { CatalogItem } from "./catalog-item";
+export { SelectableCard } from "./selectable-card";
+export type {
+ SelectableCardProps,
+ SelectableCardStylesNames,
+ SelectableCardCssVariables,
+ SelectableCardFactory,
+} from "./selectable-card";
export { MaskedImage } from "./masked-image";
export { MaskedOrNormalImage } from "./masked-or-normal-image";
export { LanguageIcon } from "./language-icon";
diff --git a/packages/ui/src/components/selectable-card/index.ts b/packages/ui/src/components/selectable-card/index.ts
new file mode 100644
index 000000000..c71cde19a
--- /dev/null
+++ b/packages/ui/src/components/selectable-card/index.ts
@@ -0,0 +1,7 @@
+export { SelectableCard } from "./selectable-card";
+export type {
+ SelectableCardProps,
+ SelectableCardStylesNames,
+ SelectableCardCssVariables,
+ SelectableCardFactory,
+} from "./selectable-card";
diff --git a/packages/ui/src/components/selectable-card/selectable-card.module.css b/packages/ui/src/components/selectable-card/selectable-card.module.css
new file mode 100644
index 000000000..b75f06176
--- /dev/null
+++ b/packages/ui/src/components/selectable-card/selectable-card.module.css
@@ -0,0 +1,79 @@
+.root {
+ cursor: pointer;
+ text-align: start;
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ width: 100%;
+ min-height: 175px;
+ height: 100%;
+ padding: var(--mantine-spacing-sm);
+ border-radius: var(--selectable-card-radius, var(--mantine-radius-md));
+ background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
+ border: 1px solid var(--mantine-color-default-border);
+ box-shadow: var(--mantine-shadow-xs);
+ transition:
+ transform 150ms ease,
+ box-shadow 150ms ease,
+ border-color 150ms ease;
+ overflow: hidden;
+ font-family: inherit;
+ color: inherit;
+ text-decoration: none;
+}
+
+.root:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: var(--mantine-shadow-sm);
+ border-color: var(--mantine-primary-color-filled);
+}
+
+.root:focus-visible {
+ outline: 2px solid var(--mantine-primary-color-filled);
+ outline-offset: 2px;
+}
+
+.root[data-selected] {
+ border-color: var(--mantine-primary-color-filled);
+ box-shadow: 0 0 0 1px var(--mantine-primary-color-filled);
+}
+
+.root:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+.header {
+ border-bottom: 1px solid var(--mantine-color-default-border);
+ background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
+ padding-top: var(--mantine-spacing-xs);
+ padding-bottom: var(--mantine-spacing-xs);
+ margin: calc(var(--mantine-spacing-sm) * -1);
+ margin-bottom: 0;
+ padding-left: var(--mantine-spacing-sm);
+ padding-right: var(--mantine-spacing-sm);
+}
+
+.body {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--mantine-spacing-xs);
+ margin-top: var(--mantine-spacing-xs);
+ margin-bottom: var(--mantine-spacing-xs);
+ min-height: 0;
+}
+
+.footer {
+ border-top: 1px dashed var(--mantine-color-default-border);
+ background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
+ padding-top: var(--mantine-spacing-xs);
+ padding-bottom: var(--mantine-spacing-xs);
+ margin: calc(var(--mantine-spacing-sm) * -1);
+ margin-top: auto;
+ padding-left: var(--mantine-spacing-sm);
+ padding-right: var(--mantine-spacing-sm);
+ min-height: 40px;
+ display: flex;
+ align-items: center;
+}
diff --git a/packages/ui/src/components/selectable-card/selectable-card.module.d.css.ts b/packages/ui/src/components/selectable-card/selectable-card.module.d.css.ts
new file mode 100644
index 000000000..3aa25b940
--- /dev/null
+++ b/packages/ui/src/components/selectable-card/selectable-card.module.d.css.ts
@@ -0,0 +1,7 @@
+declare const classes: {
+ readonly root: string;
+ readonly header: string;
+ readonly body: string;
+ readonly footer: string;
+};
+export default classes;
diff --git a/packages/ui/src/components/selectable-card/selectable-card.spec.tsx b/packages/ui/src/components/selectable-card/selectable-card.spec.tsx
new file mode 100644
index 000000000..fbecf3093
--- /dev/null
+++ b/packages/ui/src/components/selectable-card/selectable-card.spec.tsx
@@ -0,0 +1,370 @@
+// @vitest-environment jsdom
+
+import { act, createRef } from "react";
+import { createRoot } from "react-dom/client";
+import type { Root } from "react-dom/client";
+import { Badge, MantineProvider } from "@mantine/core";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { SelectableCard } from "./selectable-card";
+import classes from "./selectable-card.module.css";
+
+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("SelectableCard", () => {
+ describe("rendering slots", () => {
+ it("renders as a native button with default button type and accessible slots", async () => {
+ const onClick = vi.fn();
+ await act(async () =>
+ root.render(
+
+ Icon }
+ title="Sonarr"
+ topRight={Media }
+ description="Manage TV series downloads"
+ footerLeft={Left Meta }
+ footerRight={Right Meta }
+ onClick={onClick}
+ />
+ ,
+ ),
+ );
+
+ const button = host.querySelector("button");
+ expect(button).not.toBeNull();
+ expect(button?.type).toBe("button");
+ expect(button?.getAttribute("aria-label")).toBe("Sonarr Card");
+ expect(host.querySelector("[data-testid='card-icon']")).not.toBeNull();
+ expect(host.textContent).toContain("Sonarr");
+ expect(host.querySelector("[data-testid='card-topright']")).not.toBeNull();
+ expect(host.textContent).toContain("Manage TV series downloads");
+ expect(host.querySelector("[data-testid='card-footer-left']")).not.toBeNull();
+ expect(host.querySelector("[data-testid='card-footer-right']")).not.toBeNull();
+
+ await act(async () => button?.click());
+ expect(onClick).toHaveBeenCalledOnce();
+ });
+
+ it("renders only header when only header slots (icon, title, topRight) are provided", async () => {
+ await act(async () =>
+ root.render(
+
+ I}
+ title="Only Header"
+ topRight={TR }
+ />
+ ,
+ ),
+ );
+
+ const button = host.querySelector("button");
+ expect(button?.querySelector(`.${classes.header}`)).not.toBeNull();
+ expect(button?.querySelector(`.${classes.body}`)).toBeNull();
+ expect(button?.querySelector(`.${classes.footer}`)).toBeNull();
+ expect(host.querySelector("[data-testid='only-icon']")).not.toBeNull();
+ expect(host.querySelector("[data-testid='only-topright']")).not.toBeNull();
+ });
+
+ it("renders footer when only footerLeft or only footerRight is provided", async () => {
+ await act(async () =>
+ root.render(
+
+ Left} />
+ ,
+ ),
+ );
+
+ expect(host.querySelector(`.${classes.footer}`)).not.toBeNull();
+ expect(host.querySelector("[data-testid='left-only']")).not.toBeNull();
+
+ await act(async () =>
+ root.render(
+
+ Right} />
+ ,
+ ),
+ );
+
+ expect(host.querySelector(`.${classes.footer}`)).not.toBeNull();
+ expect(host.querySelector("[data-testid='right-only']")).not.toBeNull();
+ });
+
+ it("applies Styles API classes for root, header, body, and footer", async () => {
+ await act(async () =>
+ root.render(
+
+ Footer}
+ classNames={{
+ root: "custom-root",
+ header: "custom-header",
+ body: "custom-body",
+ footer: "custom-footer",
+ }}
+ />
+ ,
+ ),
+ );
+
+ const button = host.querySelector("button");
+ expect(button?.classList.contains(classes.root)).toBe(true);
+ expect(button?.classList.contains("custom-root")).toBe(true);
+
+ const header = button?.querySelector(`.${classes.header}`);
+ expect(header).not.toBeNull();
+ expect(header?.classList.contains("custom-header")).toBe(true);
+
+ const body = button?.querySelector(`.${classes.body}`);
+ expect(body).not.toBeNull();
+ expect(body?.classList.contains("custom-body")).toBe(true);
+
+ const footer = button?.querySelector(`.${classes.footer}`);
+ expect(footer).not.toBeNull();
+ expect(footer?.classList.contains("custom-footer")).toBe(true);
+ });
+
+ it("prioritizes title without wrapping and constrains topRight badge with ellipsis", async () => {
+ await act(async () =>
+ root.render(
+
+ Category Badge}
+ />
+ ,
+ ),
+ );
+
+ const button = host.querySelector("button");
+ const header = button?.querySelector(`.${classes.header}`);
+ expect(header).not.toBeNull();
+
+ const titleText = header?.querySelector("p, span, div");
+ expect(titleText?.textContent).toContain("Very Long Title Name That Should Not Wrap");
+
+ await act(async () =>
+ root.render(
+
+ Custom Title Element}
+ topRight={Top Right }
+ />
+ ,
+ ),
+ );
+
+ expect(host.querySelector("[data-testid='custom-title-node']")).not.toBeNull();
+ expect(host.querySelector("[data-testid='custom-top-right']")).not.toBeNull();
+ });
+
+ it("renders custom children in the body slot over description", async () => {
+ await act(async () =>
+ root.render(
+
+
+ Custom Child Content
+
+ ,
+ ),
+ );
+
+ expect(host.querySelector("[data-testid='custom-body-children']")).not.toBeNull();
+ expect(host.textContent).toContain("Custom Child Content");
+ expect(host.textContent).not.toContain("This should not appear");
+ });
+
+ it("renders an empty description without fallback copy", async () => {
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ expect(host.textContent).toContain("No Desc Item");
+ expect(host.textContent).not.toContain("No description provided");
+ });
+
+ it("omits header, body, or footer when corresponding props are omitted", async () => {
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const button = host.querySelector("button");
+ expect(button?.querySelector(`.${classes.header}`)).toBeNull();
+ expect(button?.querySelector(`.${classes.body}`)).toBeNull();
+ expect(button?.querySelector(`.${classes.footer}`)).toBeNull();
+ });
+ });
+
+ describe("states", () => {
+ it("handles selected state via data-selected attribute", async () => {
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const button = host.querySelector("button");
+ expect(button?.getAttribute("data-selected")).toBe("true");
+
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ expect(button?.hasAttribute("data-selected")).toBe(false);
+ });
+
+ it("handles disabled state and prevents click handlers", async () => {
+ const onClick = vi.fn();
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const button = host.querySelector("button");
+ expect(button?.disabled).toBe(true);
+
+ await act(async () => button?.click());
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ it("handles loading state by disabling the button and showing LoadingOverlay", async () => {
+ const onClick = vi.fn();
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const button = host.querySelector("button");
+ expect(button?.disabled).toBe(true);
+
+ await act(async () => button?.click());
+ expect(onClick).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("interactions and accessibility", () => {
+ it("supports focus and click activation", async () => {
+ const onClick = vi.fn();
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const button = host.querySelector("button");
+ button?.focus();
+ expect(document.activeElement).toBe(button);
+
+ await act(async () => {
+ button?.click();
+ });
+ expect(onClick).toHaveBeenCalledOnce();
+ });
+
+ it("forwards ref to the underlying HTMLButtonElement", async () => {
+ const ref = createRef();
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ expect(ref.current).toBeInstanceOf(HTMLButtonElement);
+ expect(ref.current?.tagName).toBe("BUTTON");
+ });
+
+ it("resolves radius CSS variables via varsResolver", async () => {
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const button = host.querySelector("button");
+ expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-lg)");
+
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-sm)");
+ });
+
+ it("passes arbitrary HTML attributes down to the button element", async () => {
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const button = host.querySelector("button");
+ expect(button?.getAttribute("data-testid")).toBe("my-selectable-card");
+ expect(button?.getAttribute("data-custom")).toBe("custom-value");
+ expect(button?.tabIndex).toBe(0);
+ });
+ });
+});
diff --git a/packages/ui/src/components/selectable-card/selectable-card.tsx b/packages/ui/src/components/selectable-card/selectable-card.tsx
new file mode 100644
index 000000000..c32944917
--- /dev/null
+++ b/packages/ui/src/components/selectable-card/selectable-card.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import type { ReactNode } from "react";
+import {
+ Box,
+ createVarsResolver,
+ factory,
+ Group,
+ LoadingOverlay,
+ Text,
+ Tooltip,
+ useProps,
+ useStyles,
+} from "@mantine/core";
+import type { BoxProps, ElementProps, Factory, MantineRadius, StylesApiProps } from "@mantine/core";
+
+import classes from "./selectable-card.module.css";
+
+export type SelectableCardStylesNames = "root" | "header" | "body" | "footer";
+export type SelectableCardCssVariables = {
+ root: "--selectable-card-radius";
+};
+
+export interface SelectableCardProps
+ extends BoxProps, StylesApiProps, Omit, "title"> {
+ icon?: ReactNode;
+ title?: ReactNode;
+ topRight?: ReactNode;
+ description?: string | null;
+ footerLeft?: ReactNode;
+ footerRight?: ReactNode;
+ selected?: boolean;
+ loading?: boolean;
+ disabled?: boolean;
+ radius?: MantineRadius;
+ children?: ReactNode;
+}
+
+export type SelectableCardFactory = Factory<{
+ props: SelectableCardProps;
+ ref: HTMLButtonElement;
+ stylesNames: SelectableCardStylesNames;
+ vars: SelectableCardCssVariables;
+}>;
+
+const defaultProps: Partial = {
+ type: "button",
+};
+
+const varsResolver = createVarsResolver((_theme, { radius }) => ({
+ root: {
+ "--selectable-card-radius": radius ? `var(--mantine-radius-${radius})` : undefined,
+ },
+}));
+
+export const SelectableCard = factory((_props) => {
+ const props = useProps("SelectableCard", defaultProps, _props);
+ const {
+ ref,
+ classNames,
+ className,
+ style,
+ styles,
+ unstyled,
+ vars,
+ attributes,
+ icon,
+ title,
+ topRight,
+ description,
+ footerLeft,
+ footerRight,
+ selected,
+ loading = false,
+ disabled = false,
+ children,
+ type = "button",
+ ...others
+ } = props;
+
+ const getStyles = useStyles({
+ name: "SelectableCard",
+ classes,
+ props,
+ className,
+ style,
+ classNames,
+ styles,
+ unstyled,
+ vars,
+ attributes,
+ varsResolver,
+ });
+
+ return (
+
+
+
+ {/* Header Section: Inset background with Title that never elides before the category */}
+ {(icon || title || topRight) && (
+
+
+
+ {icon}
+ {typeof title === "string" ? (
+
+ {title}
+
+ ) : (
+ title
+ )}
+
+ {topRight && (
+
+ {topRight}
+
+ )}
+
+
+ )}
+
+ {/* Body: Custom Children or Default Description */}
+ {children ? (
+ {children}
+ ) : description !== undefined ? (
+
+
+
+ {description}
+
+
+
+ ) : null}
+
+ {/* Footer Section: Inset Dashed Bottom Meta */}
+ {(footerLeft || footerRight) && (
+
+
+ {footerLeft}
+ {footerRight}
+
+
+ )}
+
+ );
+});
+
+SelectableCard.displayName = "@homarr/ui/SelectableCard";
+SelectableCard.classes = classes;
diff --git a/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx b/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx
new file mode 100644
index 000000000..dfd52e5b6
--- /dev/null
+++ b/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx
@@ -0,0 +1,279 @@
+// @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 } from "@homarr/definitions";
+
+import { IntegrationSelectGrid } from "./integration-select-grid";
+
+vi.mock("@homarr/definitions", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getWidgetKindsForIntegration: (kind: IntegrationKind) => {
+ if (kind === "wud") return [];
+ return actual.getWidgetKindsForIntegration(kind);
+ },
+ };
+});
+
+vi.mock("@homarr/translation/client", () => ({
+ useI18n: () => (key: string, values?: { count?: number }) => {
+ const translations: Record = {
+ "integration.page.list.search": "Search integrations",
+ "common.noResults": "No results found",
+ "integration.grid.noWidgets": "No widgets tied to this service",
+ "integration.grid.tiedWidgets": "Tied Widgets",
+ "integration.grid.connected": `${values?.count ?? 0} connected`,
+ "integration.grid.more": `+${values?.count ?? 0} more`,
+ "widget.mediaServer.name": "Media Server",
+ "widget.calendar.name": "Calendar",
+ "widget.downloads.name": "Downloads",
+ "integration.category.mediaService": "Media Service",
+ "integration.category.downloadClient": "Download Client",
+ "integration.category.calendar": "Calendar",
+ "integration.category.smartHomeServer": "Smart Home",
+ "integration.category.usenet": "Usenet",
+ "integration.category.torrent": "Torrent",
+ };
+ return translations[key] ?? key;
+ },
+}));
+
+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(),
+ }));
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ );
+ host = document.createElement("div");
+ document.body.append(host);
+ root = createRoot(host);
+});
+
+afterEach(async () => {
+ await act(async () => root.unmount());
+ host.remove();
+});
+
+describe("IntegrationSelectGrid", () => {
+ it("renders a 4-column responsive grid of selectable integration cards", async () => {
+ const onSelect = vi.fn();
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const cards = host.querySelectorAll("button");
+ expect(cards.length).toBe(4);
+ expect(host.textContent).toContain("Jellyfin");
+ expect(host.textContent).toContain("Sonarr");
+ expect(host.textContent).toContain("Radarr");
+ expect(host.textContent).toContain("SABnzbd");
+ });
+
+ it("renders tied widgets with icons and labels inside integration cards", async () => {
+ const onSelect = vi.fn();
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ expect(host.textContent).toContain("Tied Widgets");
+ // Jellyfin is tied to Media Server widget
+ expect(host.textContent).toContain("Media Server");
+ });
+
+ it("displays fallback message when integration has no tied widgets", async () => {
+ const onSelect = vi.fn();
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ expect(host.querySelector("button")).not.toBeNull();
+ expect(host.textContent).toContain("No widgets tied to this service");
+ });
+
+ it("displays '{count} connected' in footerLeft when integration instances exist", async () => {
+ const onSelect = vi.fn();
+ const integrationData = [
+ { kind: "jellyfin" as IntegrationKind, name: "Home Jellyfin" },
+ { kind: "jellyfin" as IntegrationKind, name: "Remote Jellyfin" },
+ { kind: "sonarr" as IntegrationKind, name: "Sonarr Instance" },
+ ];
+
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ // Jellyfin has 2 connected
+ expect(host.textContent).toContain("2 connected");
+
+ // Radarr has 0 connected, so it doesn't show "0 connected"
+ expect(host.textContent).not.toContain("0 connected");
+ });
+
+ it("filters integrations dynamically by search input", async () => {
+ const onSelect = vi.fn();
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const searchInput = host.querySelector("input");
+ expect(searchInput).not.toBeNull();
+
+ // Type "jelly"
+ await act(async () => {
+ if (searchInput) {
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
+ nativeInputValueSetter?.call(searchInput, "jelly");
+ searchInput.dispatchEvent(new Event("input", { bubbles: true }));
+ searchInput.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ });
+
+ const cards = host.querySelectorAll("button");
+ expect(cards.length).toBe(1);
+ expect(host.textContent).toContain("Jellyfin");
+ expect(host.textContent).not.toContain("Sonarr");
+ expect(host.textContent).not.toContain("Radarr");
+ });
+
+ it("displays empty state when no integrations match search query", async () => {
+ const onSelect = vi.fn();
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const searchInput = host.querySelector("input");
+ await act(async () => {
+ if (searchInput) {
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
+ nativeInputValueSetter?.call(searchInput, "nonexistent-integration-query");
+ searchInput.dispatchEvent(new Event("input", { bubbles: true }));
+ searchInput.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ });
+
+ expect(host.textContent).toContain("No results found");
+ expect(host.querySelectorAll("button").length).toBe(0);
+ });
+
+ it("calls onSelect when Enter is pressed with exactly 1 search match", async () => {
+ const onSelect = vi.fn();
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const searchInput = host.querySelector("input");
+ await act(async () => {
+ if (searchInput) {
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
+ nativeInputValueSetter?.call(searchInput, "jelly");
+ searchInput.dispatchEvent(new Event("input", { bubbles: true }));
+ searchInput.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ });
+
+ await act(async () => {
+ searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
+ });
+
+ expect(onSelect).toHaveBeenCalledWith("jellyfin");
+ });
+
+ it("calls onSelect when an integration card is clicked", async () => {
+ const onSelect = vi.fn();
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const cardButton = host.querySelector("button");
+ await act(async () => cardButton?.click());
+ expect(onSelect).toHaveBeenCalledWith("sonarr");
+ });
+
+ it("includes mock integration when enableMockIntegration is true", async () => {
+ const onSelect = vi.fn();
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ expect(host.textContent).toContain("Mock");
+ expect(host.textContent).toContain("Sonarr");
+ });
+
+ it("excludes mock integration when enableMockIntegration is false", async () => {
+ const onSelect = vi.fn();
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+
+ const buttons = Array.from(host.querySelectorAll("button"));
+ const mockButton = buttons.find((btn) => btn.getAttribute("aria-label")?.toLowerCase() === "mock");
+ expect(mockButton).toBeUndefined();
+ });
+});
diff --git a/packages/ui/src/integration-select-grid/integration-select-grid.tsx b/packages/ui/src/integration-select-grid/integration-select-grid.tsx
index a0cfec6d8..18d10d8e6 100644
--- a/packages/ui/src/integration-select-grid/integration-select-grid.tsx
+++ b/packages/ui/src/integration-select-grid/integration-select-grid.tsx
@@ -1,27 +1,68 @@
"use client";
import { useMemo, useState } from "react";
-import { Badge, Card, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
-import { IconPuzzle } from "@tabler/icons-react";
+import { Badge, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
+import {
+ IconAd,
+ IconApps,
+ IconBinaryTree,
+ IconBookmark,
+ IconBrandDocker,
+ IconBrandMinecraft,
+ IconBrowser,
+ IconBuildingBank,
+ IconBusStop,
+ IconCalendar,
+ IconChartBar,
+ IconClock,
+ IconClockPlay,
+ IconCloud,
+ IconDeviceCctv,
+ IconDeviceGamepad,
+ IconDownload,
+ IconGraphFilled,
+ IconHeartRateMonitor,
+ IconHourglass,
+ IconMessage,
+ IconMovie,
+ IconNotes,
+ IconPuzzle,
+ IconReportSearch,
+ IconRobot,
+ IconRocket,
+ IconRss,
+ IconSearch,
+ IconServer2,
+ IconTicket,
+ IconTopologyFull,
+ IconTransform,
+ IconVideo,
+ IconWall,
+ IconWind,
+ IconZoomQuestion,
+} from "@tabler/icons-react";
-import type { IntegrationKind } from "@homarr/definitions";
+import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "../components/integration-avatar";
-import { SelectGridLayout, selectGridCardHeight } from "../select-grid-layout";
+import { SelectableCard } from "../components/selectable-card";
+import { selectGridCols } from "../select-grid-layout";
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
-import classes from "./integration-select-grid.module.css";
+import type { IntegrationGridItem } from "./integration-grid-shared";
export interface IntegrationSelectGridProps {
onSelect: (kind: IntegrationKind) => void;
enableMockIntegration?: boolean;
allowedKinds?: readonly IntegrationKind[];
+ integrationData?: { kind: IntegrationKind; name?: string }[];
}
export const IntegrationSelectGrid = ({
onSelect,
enableMockIntegration = false,
allowedKinds,
+ integrationData,
}: IntegrationSelectGridProps) => {
const [search, setSearch] = useState("");
const t = useI18n();
@@ -32,75 +73,182 @@ export const IntegrationSelectGrid = ({
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
return (
-
- {filtered.map((integration) => (
- onSelect(integration.kind)}
- aria-label={integration.name}
- >
-
-
-
-
-
- {integration.name}
-
-
-
-
-
-
- ))}
- {filtered.length === 0 && (
-
- {t("common.noResults")}
-
- )}
-
- );
-};
-
-const IntegrationGridMeta = ({ integration }: { integration: ReturnType[number] }) => {
- const t = useI18n();
- return (
-
-
- {integration.categories.slice(0, 2).map((category) => (
-
- {categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
-
- ))}
-
- {integration.widgets.length > 0 ? (
-
- {integration.widgets.map((widgetKind) => (
-
- {t(`widget.${widgetKind}.name`)}
-
- ))}
-
+
+ {/* Top Search Input */}
+ setSearch(event.currentTarget.value)}
+ leftSection={ }
+ placeholder={`${t("integration.page.list.search")}...`}
+ aria-label={t("integration.page.list.search")}
+ data-autofocus
+ onKeyDown={(event) => {
+ if (event.key === "Enter" && filtered.length === 1 && filtered[0]) {
+ onSelect(filtered[0].kind);
}
- >
- }>
- {t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
-
-
- ) : (
-
- {t("integration.grid.noWidgets")}
-
- )}
+ }}
+ />
+
+ {/* Scrollable Container with Integration Cards */}
+
+
+
+ {filtered.map((integration) => (
+ i.kind === integration.kind).length}
+ />
+ ))}
+
+
+ {filtered.length === 0 && (
+
+ {t("common.noResults")}
+
+ )}
+
+
);
};
+
+// =========================================================================
+// IntegrationCard: Shared Modular SelectableCard with Tied Widgets
+// =========================================================================
+const IntegrationCard = ({
+ integration,
+ onSelect,
+ connectedCount = 0,
+}: {
+ integration: IntegrationGridItem;
+ onSelect: (kind: IntegrationKind) => void;
+ connectedCount?: number;
+}) => {
+ const t = useI18n();
+
+ return (
+ onSelect(integration.kind)}
+ aria-label={integration.name}
+ icon={ }
+ title={integration.name}
+ topRight={ }
+ footerLeft={
+ connectedCount > 0 ? (
+
+ {t("integration.grid.connected", { count: connectedCount })}
+
+ ) : null
+ }
+ >
+
+ {t("integration.grid.tiedWidgets")}
+
+
+
+ );
+};
+
+const widgetIconsMap: Partial> = {
+ clock: IconClock,
+ weather: IconCloud,
+ airQuality: IconWind,
+ countdown: IconHourglass,
+ timer: IconClockPlay,
+ app: IconApps,
+ assistant: IconRobot,
+ iframe: IconBrowser,
+ video: IconDeviceCctv,
+ notebook: IconNotes,
+ anchorNote: IconNotes,
+ dnsHoleSummary: IconAd,
+ dnsHoleControls: IconDeviceGamepad,
+ "smartHome-entityState": IconBinaryTree,
+ "smartHome-executeAutomation": IconBinaryTree,
+ stockPrice: IconBuildingBank,
+ mediaServer: IconVideo,
+ calendar: IconCalendar,
+ downloads: IconDownload,
+ "mediaRequests-requestList": IconZoomQuestion,
+ "mediaRequests-requestStats": IconChartBar,
+ mediaTranscoding: IconTransform,
+ mediaMissing: IconMovie,
+ minecraftServerStatus: IconBrandMinecraft,
+ networkControllerSummary: IconTopologyFull,
+ networkControllerStatus: IconTopologyFull,
+ rssFeed: IconRss,
+ bookmarks: IconBookmark,
+ indexerManager: IconReportSearch,
+ healthMonitoring: IconHeartRateMonitor,
+ releases: IconRocket,
+ mediaReleases: IconTicket,
+ dockerContainers: IconBrandDocker,
+ firewall: IconWall,
+ notifications: IconMessage,
+ systemResources: IconGraphFilled,
+ coolify: IconCloud,
+ systemDisks: IconServer2,
+ timetable: IconBusStop,
+};
+
+const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; limit?: number }) => {
+ const t = useI18n();
+ if (widgets.length === 0) {
+ return (
+
+ {t("integration.grid.noWidgets")}
+
+ );
+ }
+
+ const items = limit ? widgets.slice(0, limit) : widgets;
+ const moreCount = limit ? widgets.length - limit : 0;
+
+ return (
+
+ {items.map((widgetKind) => {
+ const WidgetIcon = widgetIconsMap[widgetKind] ?? IconPuzzle;
+ return (
+ }
+ style={{ fontWeight: 500 }}
+ >
+ {t(`widget.${widgetKind}.name`)}
+
+ );
+ })}
+ {moreCount > 0 && (
+
+ {t("integration.grid.more", { count: moreCount })}
+
+ )}
+
+ );
+};
+
+const IntegrationCategoryBadges = ({
+ categories,
+ limit,
+}: {
+ categories: IntegrationGridItem["categories"];
+ limit?: number;
+}) => {
+ const t = useI18n();
+ const items = limit ? categories.slice(0, limit) : categories;
+
+ return (
+
+ {items.map((category) => (
+
+ {categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
+
+ ))}
+
+ );
+};
diff --git a/packages/widgets/package.json b/packages/widgets/package.json
index 034197e44..8580c9ed8 100644
--- a/packages/widgets/package.json
+++ b/packages/widgets/package.json
@@ -13,7 +13,9 @@
},
"exports": {
".": "./index.ts",
- "./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
+ "./inputs": "./src/_inputs/index.ts",
+ "./inputs/form": "./src/_inputs/form.ts",
+ "./inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./custom-api/component": "./src/custom-api/component.tsx",
"./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx",
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
diff --git a/packages/widgets/src/_inputs/widget-app-input.tsx b/packages/widgets/src/_inputs/widget-app-input.tsx
index 933f7daf9..313d7243f 100644
--- a/packages/widgets/src/_inputs/widget-app-input.tsx
+++ b/packages/widgets/src/_inputs/widget-app-input.tsx
@@ -17,6 +17,12 @@ import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
+const renderHereLink = (children: React.ReactNode) => (
+
+ {children}
+
+);
+
export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">) => {
const t = useI18n();
const tInput = useWidgetInputTranslation(kind, property);
@@ -49,13 +55,9 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
}
inputWrapperOrder={["label", "input", "description", "error"]}
description={
-
+
{t.rich("widget.common.app.description", {
- here: () => (
-
- {t("common.here")}
-
- ),
+ here: renderHereLink,
})}
}
diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx
index 8475de4e0..d78be5d24 100644
--- a/packages/widgets/src/app/component.tsx
+++ b/packages/widgets/src/app/component.tsx
@@ -26,7 +26,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
- const appQuery = clientApi.app.byId.useQuery({ id: options.appId });
+ const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
const app = getUsableWidgetQueryData(appQuery);
const href = getSafeAppHref(app?.href);
useRegisterSpotlightContextResults(
@@ -50,6 +50,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
[app, href, options.openInNewTab],
);
+ if (!options.appId) return ;
if (isInitialWidgetQueryPending(appQuery)) return ;
if (!app) return ;
diff --git a/packages/widgets/src/catalog.ts b/packages/widgets/src/catalog.ts
index 6ca945d47..3287a15d5 100644
--- a/packages/widgets/src/catalog.ts
+++ b/packages/widgets/src/catalog.ts
@@ -15,7 +15,6 @@ import {
IconBuildingBank,
IconBusStop,
IconCalendar,
- IconCalendarWeek,
IconChartAreaLine,
IconChartBar,
IconClock,
@@ -60,7 +59,6 @@ import type { TablerIcon } from "@homarr/ui";
export const widgetCatalogIcons: Record = {
clock: IconClock,
weather: IconCloud,
- today: IconCalendarWeek,
airQuality: IconWind,
countdown: IconHourglass,
timer: IconClockPlay,
diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx
index 060185a39..86ea2599d 100644
--- a/packages/widgets/src/index.tsx
+++ b/packages/widgets/src/index.tsx
@@ -63,7 +63,6 @@ import * as beszelAlerts from "./beszel-alerts";
import * as beszelSystemStats from "./beszel-system-stats";
import * as video from "./video";
import * as weather from "./weather";
-import * as today from "./today";
import * as airQuality from "./air-quality";
import * as countdown from "./countdown";
import * as timer from "./timer";
@@ -98,7 +97,6 @@ export { AssistantWidgetRendererProvider } from "./assistant/context";
export const widgetImports = {
clock,
weather,
- today,
airQuality,
countdown,
timer,
diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts
index 9d1cb6e6d..09795db10 100644
--- a/packages/widgets/src/manifest.ts
+++ b/packages/widgets/src/manifest.ts
@@ -24,7 +24,6 @@ export type WidgetResources = {
const moduleLoaders: Record Promise> = {
clock: () => import("./clock") as Promise,
weather: () => import("./weather") as Promise,
- today: () => import("./today") as Promise,
airQuality: () => import("./air-quality") as Promise,
countdown: () => import("./countdown") as Promise,
timer: () => import("./timer") as Promise,
@@ -89,7 +88,6 @@ const moduleLoaders: Record Promise> = {
const componentLoaders: Record Promise> = {
clock: () => import("./clock/component") as Promise,
weather: () => import("./weather/component") as Promise,
- today: () => import("./today/component") as Promise,
airQuality: () => import("./air-quality/component") as Promise,
countdown: () => import("./countdown/component") as Promise,
timer: () => import("./timer/component") as Promise,
diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx
index 36ec3815b..bd0d9c835 100644
--- a/packages/widgets/src/modals/widget-edit-modal.tsx
+++ b/packages/widgets/src/modals/widget-edit-modal.tsx
@@ -58,6 +58,7 @@ export interface WidgetEditModalProps {
appId?: string;
integrationEditForm?: ComponentType;
onIntegrationSaved?: () => void;
+ onOpenNewIntegration?: (onCreated?: (id: string) => void) => void;
}
export const getSelectedWidgetIntegrations = (
@@ -217,6 +218,17 @@ export const WidgetEditModal = createModal>(({
handleSubmit(event);
};
+ const canCreateIntegration = maxIntegrations === 1 || form.values.integrationIds.length < maxIntegrations;
+ const handleOpenNewIntegration = innerProps.onOpenNewIntegration
+ ? () => {
+ innerProps.onOpenNewIntegration?.((newId) => {
+ if (newId) {
+ form.setFieldValue("integrationIds", [...(maxIntegrations > 1 ? form.values.integrationIds : []), newId]);
+ }
+ });
+ }
+ : undefined;
+
const widgetFormContent = (
{canConfigureWidget && innerProps.integrationSupport && (
@@ -225,6 +237,7 @@ export const WidgetEditModal = createModal>(({
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
+ onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
/>
)}
@@ -413,6 +426,5 @@ export const WidgetEditModal = createModal>(({
return t("item.edit.title");
},
size: modalSizeForm,
- presentation: "inspector",
closeOnClickOutside: false,
});
diff --git a/packages/widgets/src/today/component.tsx b/packages/widgets/src/today/component.tsx
deleted file mode 100644
index 9b7493577..000000000
--- a/packages/widgets/src/today/component.tsx
+++ /dev/null
@@ -1,223 +0,0 @@
-"use client";
-
-import {
- Badge,
- Box,
- Group,
- Paper,
- Progress,
- RingProgress,
- ScrollArea,
- SimpleGrid,
- Stack,
- Text,
- VisuallyHidden,
-} from "@mantine/core";
-
-import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
-
-import { formatLocalizedDate } from "../common/locale";
-import { useWidgetNow } from "../common/use-widget-now";
-import type { WidgetComponentProps } from "../definition";
-import { getTodayLayout, getTodayStats } from "./date-stats";
-
-const formatProgress = (value: number) => Math.round(value);
-
-export default function TodayWidget({ options, width, height, displayMode }: WidgetComponentProps<"today">) {
- const now = useWidgetNow("minute");
- const locale = useCurrentIntlLocale();
- const t = useScopedI18n("widget.today");
-
- if (now === null) {
- return (
-
- —
-
- );
- }
-
- const stats = getTodayStats(now, options.weekConvention, locale);
- if (displayMode === "advanced") {
- return ;
- }
-
- const layout = getTodayLayout(width, height);
- const weekday = formatLocalizedDate(now, locale, { weekday: "long" });
- const month = formatLocalizedDate(now, locale, { month: "long" });
- const fullDate = formatLocalizedDate(now, locale, { dateStyle: "long" });
-
- if (layout.tiny) {
- return (
-
-
- {weekday} · {month}
-
-
- {now.getDate()}
-
-
- );
- }
-
- return (
-
-
-
-
- {weekday}
-
-
- {layout.showFullDate ? fullDate : `${month} ${now.getDate()}`}
-
-
- {options.showYearProgress && (
-
-
- {formatProgress(stats.yearProgress)}%
-
- }
- />
-
-
- {t("yearProgress", { progress: formatProgress(stats.yearProgress) })}
-
-
-
- )}
-
-
- {layout.showStats && (
-
- {options.showWeekNumber && {t("weekNumber", { week: stats.weekNumber })} }
- {options.showDayOfYear && (
- {t("dayOfYear", { day: stats.dayOfYear, total: stats.daysInYear })}
- )}
- {layout.showQuarter && options.showQuarter && (
- {t("quarter", { quarter: stats.quarter })}
- )}
- {layout.showQuarter && (
-
- {t("daysAfterToday", { days: stats.daysAfterToday })}
-
- )}
-
- )}
-
- {layout.showMonthProgress && }
-
- );
-}
-
-interface AdvancedTodayProps {
- now: Date;
- locale: string;
- options: WidgetComponentProps<"today">["options"];
- stats: ReturnType;
-}
-
-const AdvancedToday = ({ now, locale, options, stats }: AdvancedTodayProps) => {
- const t = useScopedI18n("widget.today");
- const numberFormatter = new Intl.NumberFormat(locale);
- const visibleMetrics = [
- options.showWeekNumber && { label: t("advanced.week"), value: numberFormatter.format(stats.weekNumber) },
- options.showDayOfYear && {
- label: t("advanced.dayOfYear"),
- value: `${numberFormatter.format(stats.dayOfYear)} / ${numberFormatter.format(stats.daysInYear)}`,
- },
- options.showQuarter && {
- label: t("advanced.quarter"),
- value: t("advanced.quarterValue", { quarter: stats.quarter }),
- },
- { label: t("advanced.daysRemaining"), value: numberFormatter.format(stats.daysAfterToday) },
- ].filter((metric): metric is { label: string; value: string } => Boolean(metric));
-
- return (
-
-
-
-
- {formatLocalizedDate(now, locale, { dateStyle: "full" })}
-
- {options.showWeekNumber && (
-
- {t(`weekConvention.${options.weekConvention}`)}
-
- )}
-
-
-
- {visibleMetrics.map((metric) => (
-
-
- {metric.label}
-
-
- {metric.value}
-
-
- ))}
-
-
-
-
-
- {options.showYearProgress && }
-
-
-
- {Array.from({ length: 12 }, (_, monthIndex) => {
- const date = new Date(now.getFullYear(), monthIndex, 1);
- const isCurrent = monthIndex === stats.monthIndex;
- return (
-
-
- {formatLocalizedDate(date, locale, { month: "short" })}
-
- {isCurrent && (
-
- {now.getDate()}
-
- )}
-
- );
- })}
-
-
-
- );
-};
-
-const ProgressMetric = ({ label, value }: { label: string; value: number }) => {
- const roundedValue = formatProgress(value);
- return (
-
-
-
- {label}
-
-
- {roundedValue}%
-
-
-
-
- );
-};
-
-const toDateTime = (date: Date) => {
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, "0");
- const day = String(date.getDate()).padStart(2, "0");
- return `${year}-${month}-${day}`;
-};
diff --git a/packages/widgets/src/today/date-stats.spec.ts b/packages/widgets/src/today/date-stats.spec.ts
deleted file mode 100644
index 414732dfa..000000000
--- a/packages/widgets/src/today/date-stats.spec.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { getTodayLayout, getTodayStats } from "./date-stats";
-
-describe("today date statistics", () => {
- it("accounts for leap years", () => {
- const stats = getTodayStats(new Date(2024, 11, 31, 12), "iso");
- expect(stats.dayOfYear).toBe(366);
- expect(stats.daysInYear).toBe(366);
- expect(stats.daysAfterToday).toBe(0);
- });
-
- it("uses the requested week convention", () => {
- const date = new Date(2023, 0, 1, 12);
- expect(getTodayStats(date, "iso").weekNumber).toBe(52);
- expect(getTodayStats(date, "locale", "en-US").weekNumber).toBe(1);
- expect(getTodayStats(date, "locale", "de-DE").weekNumber).toBe(52);
- });
-
- it("returns bounded calendar progress", () => {
- const stats = getTodayStats(new Date(2026, 7, 20, 12), "iso");
- expect(stats.quarter).toBe(3);
- expect(stats.weekProgress).toBeGreaterThanOrEqual(0);
- expect(stats.weekProgress).toBeLessThanOrEqual(100);
- expect(stats.monthProgress).toBeGreaterThanOrEqual(0);
- expect(stats.monthProgress).toBeLessThanOrEqual(100);
- expect(stats.yearProgress).toBeGreaterThanOrEqual(0);
- expect(stats.yearProgress).toBeLessThanOrEqual(100);
- });
-});
-
-describe("today compact layout", () => {
- it("keeps tiny widgets focused on the date", () => {
- expect(getTodayLayout(140, 120)).toMatchObject({ tiny: true, showStats: false });
- });
-
- it("reveals secondary information only when both axes have room", () => {
- expect(getTodayLayout(240, 120)).toMatchObject({ showStats: true, showQuarter: false });
- expect(getTodayLayout(300, 180)).toEqual({
- tiny: false,
- showFullDate: true,
- showStats: true,
- showQuarter: true,
- showMonthProgress: true,
- });
- });
-});
diff --git a/packages/widgets/src/today/date-stats.ts b/packages/widgets/src/today/date-stats.ts
deleted file mode 100644
index 790d41016..000000000
--- a/packages/widgets/src/today/date-stats.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-import dayjs from "dayjs";
-import dayOfYear from "dayjs/plugin/dayOfYear";
-import quarterOfYear from "dayjs/plugin/quarterOfYear";
-
-dayjs.extend(dayOfYear);
-dayjs.extend(quarterOfYear);
-
-export type WeekConvention = "locale" | "iso";
-
-export interface TodayStats {
- dayOfYear: number;
- daysInYear: number;
- daysAfterToday: number;
- monthIndex: number;
- quarter: number;
- weekNumber: number;
- weekProgress: number;
- monthProgress: number;
- yearProgress: number;
-}
-
-const getProgress = (value: number, start: number, end: number) => {
- if (end <= start) return 0;
- return Math.min(100, Math.max(0, ((value - start) / (end - start)) * 100));
-};
-
-export const getTodayStats = (date: Date, weekConvention: WeekConvention, locale = "en-US"): TodayStats => {
- const value = dayjs(date);
- const yearStart = value.startOf("year");
- const nextYearStart = yearStart.add(1, "year");
- const monthStart = value.startOf("month");
- const nextMonthStart = monthStart.add(1, "month");
- const weekRules = getWeekRules(weekConvention, locale);
- const weekDay = value.day() === 0 ? 7 : value.day();
- const daysSinceWeekStart = (weekDay - weekRules.firstDay + 7) % 7;
- const weekStart = new Date(date.getFullYear(), date.getMonth(), date.getDate() - daysSinceWeekStart);
- const nextWeekStart = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + 7);
- const currentDayOfYear = value.dayOfYear();
- const daysInYear = yearStart.endOf("year").dayOfYear();
-
- return {
- dayOfYear: currentDayOfYear,
- daysInYear,
- daysAfterToday: daysInYear - currentDayOfYear,
- monthIndex: value.month(),
- quarter: value.quarter(),
- weekNumber: getWeekNumber(date, weekRules),
- weekProgress: getProgress(value.valueOf(), weekStart.getTime(), nextWeekStart.getTime()),
- monthProgress: getProgress(value.valueOf(), monthStart.valueOf(), nextMonthStart.valueOf()),
- yearProgress: getProgress(value.valueOf(), yearStart.valueOf(), nextYearStart.valueOf()),
- };
-};
-
-interface WeekRules {
- firstDay: number;
- minimalDays: number;
-}
-
-interface LocaleWeekInfo {
- firstDay: number;
- minimalDays?: number;
-}
-
-type LocaleWithWeekInfo = Intl.Locale & {
- weekInfo?: LocaleWeekInfo;
- getWeekInfo?: () => LocaleWeekInfo;
-};
-
-const fourDayMinimumRegions = new Set([
- "AD",
- "AN",
- "AT",
- "AX",
- "BE",
- "BG",
- "CH",
- "CZ",
- "DE",
- "DK",
- "EE",
- "ES",
- "FI",
- "FJ",
- "FO",
- "FR",
- "GF",
- "GB",
- "GG",
- "GI",
- "GP",
- "GR",
- "IE",
- "IM",
- "IS",
- "IT",
- "LI",
- "LT",
- "LU",
- "MC",
- "MQ",
- "NL",
- "NO",
- "PL",
- "PT",
- "RE",
- "RU",
- "SE",
- "SJ",
- "SK",
- "VA",
-]);
-
-const getWeekRules = (convention: WeekConvention, locale: string): WeekRules => {
- if (convention === "iso") return { firstDay: 1, minimalDays: 4 };
- try {
- const localeInfo = new Intl.Locale(locale) as LocaleWithWeekInfo;
- const weekInfo = localeInfo.weekInfo ?? localeInfo.getWeekInfo?.();
- if (weekInfo) {
- const region = localeInfo.maximize().region;
- const fallbackMinimalDays = region && fourDayMinimumRegions.has(region) ? 4 : 1;
- return { firstDay: weekInfo.firstDay, minimalDays: weekInfo.minimalDays ?? fallbackMinimalDays };
- }
- } catch {
- // Invalid locales fall back to the common US week convention.
- }
- return { firstDay: 7, minimalDays: 1 };
-};
-
-const getWeekNumber = (date: Date, rules: WeekRules): number => {
- const dateDay = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
- let weekYear = date.getFullYear();
- let firstWeekStart = getFirstWeekStart(weekYear, rules);
-
- if (dateDay < firstWeekStart) {
- weekYear -= 1;
- firstWeekStart = getFirstWeekStart(weekYear, rules);
- } else {
- const nextFirstWeekStart = getFirstWeekStart(weekYear + 1, rules);
- if (dateDay >= nextFirstWeekStart) return 1;
- }
-
- return Math.floor((dateDay - firstWeekStart) / (7 * 86_400_000)) + 1;
-};
-
-const getFirstWeekStart = (year: number, rules: WeekRules): number => {
- const januaryFirst = Date.UTC(year, 0, 1);
- const sundayBasedWeekDay = new Date(januaryFirst).getUTCDay();
- const weekDay = sundayBasedWeekDay === 0 ? 7 : sundayBasedWeekDay;
- const daysBeforeJanuary = (weekDay - rules.firstDay + 7) % 7;
- const daysInFirstWeek = 7 - daysBeforeJanuary;
- if (daysInFirstWeek >= rules.minimalDays) return januaryFirst - daysBeforeJanuary * 86_400_000;
- return januaryFirst + daysInFirstWeek * 86_400_000;
-};
-
-export interface TodayLayout {
- tiny: boolean;
- showFullDate: boolean;
- showStats: boolean;
- showQuarter: boolean;
- showMonthProgress: boolean;
-}
-
-export const getTodayLayout = (width: number, height: number): TodayLayout => {
- const tiny = width < 150 || height < 96;
- return {
- tiny,
- showFullDate: !tiny && width >= 180,
- showStats: !tiny && width >= 210 && height >= 115,
- showQuarter: width >= 240 && height >= 145,
- showMonthProgress: width >= 300 && height >= 170,
- };
-};
diff --git a/packages/widgets/src/today/index.ts b/packages/widgets/src/today/index.ts
deleted file mode 100644
index e494df35b..000000000
--- a/packages/widgets/src/today/index.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { IconCalendarWeek } from "@tabler/icons-react";
-
-import { createWidgetDefinition } from "../definition";
-import { optionsBuilder } from "../options";
-
-export const { definition, componentLoader } = createWidgetDefinition("today", {
- icon: IconCalendarWeek,
- supportsAdvancedFocus: true,
- createOptions() {
- return optionsBuilder.from((factory) => ({
- weekConvention: factory.select({
- defaultValue: "locale",
- options: [
- { value: "locale", label: (t) => t("widget.today.option.weekConvention.options.locale") },
- { value: "iso", label: (t) => t("widget.today.option.weekConvention.options.iso") },
- ],
- }),
- showWeekNumber: factory.switch({ defaultValue: true }),
- showDayOfYear: factory.switch({ defaultValue: true }),
- showQuarter: factory.switch({ defaultValue: true }),
- showYearProgress: factory.switch({ defaultValue: true }),
- }));
- },
-}).withDynamicImport(() => import("./component"));
diff --git a/packages/widgets/src/widget-integration-select.tsx b/packages/widgets/src/widget-integration-select.tsx
index ed82c4099..e24418395 100644
--- a/packages/widgets/src/widget-integration-select.tsx
+++ b/packages/widgets/src/widget-integration-select.tsx
@@ -32,6 +32,7 @@ interface WidgetIntegrationSelectProps {
canSelectMultiple?: boolean;
data: IntegrationSelectOption[];
withAsterisk?: boolean;
+ onOpenNewIntegration?: () => void;
}
export const WidgetIntegrationSelect = ({
data,
@@ -40,6 +41,7 @@ export const WidgetIntegrationSelect = ({
label,
canSelectMultiple = true,
withAsterisk = false,
+ onOpenNewIntegration,
...props
}: WidgetIntegrationSelectProps) => {
const t = useI18n();
@@ -109,11 +111,19 @@ export const WidgetIntegrationSelect = ({
-
- {t("widget.common.integration.manage")}
-
-
+ onOpenNewIntegration ? (
+
+
+ {t("integration.action.create")}
+
+
+ ) : (
+
+
+ {t("widget.common.integration.manage")}
+
+
+ )
}
pointer
onClick={() => combobox.openDropdown()}