From 50bd56edb9776aad8abdd5ec382ab23ee12f5930 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 21 Aug 2026 02:55:47 +0200 Subject: [PATCH] fix(board): harden selection and creation flows --- apps/docs/docs/management/boards/index.mdx | 17 +++--- .../board/sections/grid/fixed-grid-item.tsx | 1 + .../selection/board-selection-context.tsx | 15 ++++- .../board/selection/board-selection.spec.tsx | 60 +++++++++++++++++++ .../integration/integration-select-modal.tsx | 17 +++++- .../src/apps/app-select-modal.spec.tsx | 8 ++- .../src/apps/app-select-modal.tsx | 4 +- packages/translation/src/lang/en.json | 7 ++- .../selectable-card/selectable-card.spec.tsx | 5 +- .../integration-select-grid.spec.tsx | 5 +- .../integration-select-grid.tsx | 3 +- .../widgets/src/modals/widget-edit-modal.tsx | 3 +- 12 files changed, 119 insertions(+), 26 deletions(-) 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. -A focused board tile in keyboard editing mode +A focused board tile in keyboard editing mode #### 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")} /> )}