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/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx b/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx
index 07dadd7b6..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
@@ -200,6 +200,7 @@ export const FixedGridItem = ({
data-kind={item.kind}
data-keyboard-editing={isEditorActive ? String(isKeyboardEditing) : undefined}
aria-describedby={isEditorActive ? instructionsId : undefined}
+ aria-selected={isEditorActive && item.type === "item" ? selected : undefined}
aria-keyshortcuts={
isEditorActive
? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown"
diff --git a/apps/nextjs/src/components/board/selection/board-selection-context.tsx b/apps/nextjs/src/components/board/selection/board-selection-context.tsx
index a9395241f..c786faff1 100644
--- a/apps/nextjs/src/components/board/selection/board-selection-context.tsx
+++ b/apps/nextjs/src/components/board/selection/board-selection-context.tsx
@@ -136,6 +136,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
if (selectedItemIds.size === 0) return;
const idsToMove = new Set(selectedItemIds);
+ let didMoveAll = false;
persistBoard((previous) => {
const targetSection = previous.sections.find((section) => section.id === targetSectionId);
@@ -218,7 +219,8 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
occupiedPositions.push({ id: item.id, ...emptyPosition, width, height });
}
- if (movedLayouts.size === 0) return previous;
+ if (movedLayouts.size !== idsToMove.size) return previous;
+ didMoveAll = true;
return {
...previous,
items: previous.items.map((item) => {
@@ -232,9 +234,16 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
};
});
- clearSelection();
+ if (didMoveAll) {
+ clearSelection();
+ } else {
+ showErrorNotification({
+ title: tSelection("moveUnavailableTitle"),
+ message: tSelection("moveUnavailableMessage"),
+ });
+ }
},
- [clearSelection, currentLayoutId, persistBoard, selectedItemIds],
+ [clearSelection, currentLayoutId, persistBoard, selectedItemIds, tSelection],
);
const value = useMemo(
diff --git a/apps/nextjs/src/components/board/selection/board-selection.spec.tsx b/apps/nextjs/src/components/board/selection/board-selection.spec.tsx
index de73a3424..21fcc13ba 100644
--- a/apps/nextjs/src/components/board/selection/board-selection.spec.tsx
+++ b/apps/nextjs/src/components/board/selection/board-selection.spec.tsx
@@ -188,4 +188,64 @@ describe("BoardSelectionContext", () => {
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/integration/integration-select-modal.tsx b/apps/nextjs/src/components/integration/integration-select-modal.tsx
index 76f8062a4..3bb68169d 100644
--- a/apps/nextjs/src/components/integration/integration-select-modal.tsx
+++ b/apps/nextjs/src/components/integration/integration-select-modal.tsx
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
-import { Group, ScrollArea, Title, UnstyledButton } from "@mantine/core";
+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";
@@ -34,7 +34,7 @@ export const IntegrationSelectModal = createModal((
const [step, setStep] = useState<"select" | "form">(isDirectForm ? "form" : "select");
const [selectedKind, setSelectedKind] = useState(directKind ?? null);
const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
- const { data: integrationData } = clientApi.integration.all.useQuery();
+ const { data: integrationData, isError: isIntegrationDataError, refetch } = clientApi.integration.all.useQuery();
useEffect(() => {
if (step === "form" && !isDirectForm) {
@@ -95,6 +95,19 @@ export const IntegrationSelectModal = createModal((
);
}
+ if (isIntegrationDataError) {
+ return (
+
+
+ {t("integration.grid.loadError")}
+
+
+
+ );
+ }
+
return (
{
});
vi.mock("@homarr/translation/client", () => ({
- useI18n: () => (key: string) => {
+ 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",
diff --git a/packages/modals-collection/src/apps/app-select-modal.tsx b/packages/modals-collection/src/apps/app-select-modal.tsx
index c5f6a50cf..ab2b5a63f 100644
--- a/packages/modals-collection/src/apps/app-select-modal.tsx
+++ b/packages/modals-collection/src/apps/app-select-modal.tsx
@@ -19,7 +19,7 @@ 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 { SelectableCard } from "@homarr/ui";
+import { selectGridCols, SelectableCard } from "@homarr/ui";
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
@@ -31,8 +31,6 @@ interface AppSelectModalProps {
withCreate: boolean;
}
-const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
-
export const AppSelectModal = createModal(({ actions, innerProps }) => {
const [search, setSearch] = useState("");
const [selectedAppIds, setSelectedAppIds] = useState>(new Set());
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 8e602c744..492bd1880 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -1674,7 +1674,8 @@
"detected": "Detected via Docker",
"connected": "{count, plural, one {# connected} other {# connected}}",
"tiedWidgets": "Tied widgets",
- "more": "+{count} more"
+ "more": "+{count} more",
+ "loadError": "Integrations could not be loaded. Try again before creating a new connection."
},
"category": {
"dnsHole": "DNS",
@@ -2792,7 +2793,9 @@
"clear": "Clear selection",
"clearShortcut": "Clear selection (Escape)",
"saveErrorTitle": "Unable to update items",
- "saveErrorMessage": "The changes could not be saved. The board was refreshed."
+ "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",
diff --git a/packages/ui/src/components/selectable-card/selectable-card.spec.tsx b/packages/ui/src/components/selectable-card/selectable-card.spec.tsx
index c011f27d3..fbecf3093 100644
--- a/packages/ui/src/components/selectable-card/selectable-card.spec.tsx
+++ b/packages/ui/src/components/selectable-card/selectable-card.spec.tsx
@@ -201,7 +201,7 @@ describe("SelectableCard", () => {
expect(host.textContent).not.toContain("This should not appear");
});
- it("renders description with fallback when description is empty string", async () => {
+ it("renders an empty description without fallback copy", async () => {
await act(async () =>
root.render(
@@ -210,7 +210,8 @@ describe("SelectableCard", () => {
),
);
- expect(host.textContent).toContain("No description provided");
+ 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 () => {
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
index c7e736e1d..dfd52e5b6 100644
--- a/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx
+++ b/packages/ui/src/integration-select-grid/integration-select-grid.spec.tsx
@@ -22,11 +22,14 @@ vi.mock("@homarr/definitions", async (importOriginal) => {
});
vi.mock("@homarr/translation/client", () => ({
- useI18n: () => (key: string) => {
+ 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",
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 4c6dbb354..18d10d8e6 100644
--- a/packages/ui/src/integration-select-grid/integration-select-grid.tsx
+++ b/packages/ui/src/integration-select-grid/integration-select-grid.tsx
@@ -47,6 +47,7 @@ import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "../components/integration-avatar";
import { SelectableCard } from "../components/selectable-card";
+import { selectGridCols } from "../select-grid-layout";
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
import type { IntegrationGridItem } from "./integration-grid-shared";
@@ -57,8 +58,6 @@ export interface IntegrationSelectGridProps {
integrationData?: { kind: IntegrationKind; name?: string }[];
}
-const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
-
export const IntegrationSelectGrid = ({
onSelect,
enableMockIntegration = false,
diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx
index 1bdf03f4c..bd0d9c835 100644
--- a/packages/widgets/src/modals/widget-edit-modal.tsx
+++ b/packages/widgets/src/modals/widget-edit-modal.tsx
@@ -218,6 +218,7 @@ export const WidgetEditModal = createModal>(({
handleSubmit(event);
};
+ const canCreateIntegration = maxIntegrations === 1 || form.values.integrationIds.length < maxIntegrations;
const handleOpenNewIntegration = innerProps.onOpenNewIntegration
? () => {
innerProps.onOpenNewIntegration?.((newId) => {
@@ -236,7 +237,7 @@ export const WidgetEditModal = createModal>(({
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
- onOpenNewIntegration={handleOpenNewIntegration}
+ onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
/>
)}