fix(board): harden selection and creation flows

This commit is contained in:
Thomas Camlong committed 2026-08-21 02:55:47 +02:00
1 parent 71d307a9cd
commit 50bd56edb9
12 files changed
+119 -26

No files matched your search

+8 -9
View File
@@ -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.
<img
src={keyboardTileEditingImage}
alt="A focused board tile in keyboard editing mode"
style={{ maxWidth: "100%" }}
/>
<img src={keyboardTileEditingImage} alt="A focused board tile in keyboard editing mode" style={{ maxWidth: "100%" }} />
#### Preview
@@ -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"
@@ -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(
@@ -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<typeof useBoardSelection>;
await act(async () => {
root.render(
<MantineProvider>
<BoardSelectionProvider>
<SelectionConsumer
onReady={(ctx) => {
selectionContext = ctx;
}}
/>
</BoardSelectionProvider>
</MantineProvider>,
);
});
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);
});
});
@@ -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<IntegrationSelectModalProps>((
const [step, setStep] = useState<"select" | "form">(isDirectForm ? "form" : "select");
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(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<IntegrationSelectModalProps>((
);
}
if (isIntegrationDataError) {
return (
<Alert color="red" title={t("common.error")}>
<Stack gap="sm">
<Text size="sm">{t("integration.grid.loadError")}</Text>
<Button variant="light" color="red" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Alert>
);
}
return (
<IntegrationSelectGrid
onSelect={handleSelect}
@@ -62,13 +62,19 @@ vi.mock("@homarr/modals", async () => {
});
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => {
useI18n: () => (key: string, values?: { count?: number }) => {
const count = values?.count ?? 0;
const translations: Record<string, string> = {
"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",
@@ -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<AppSelectModalProps>(({ actions, innerProps }) => {
const [search, setSearch] = useState("");
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
+5 -2
View File
@@ -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",
@@ -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(
<MantineProvider>
@@ -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 () => {
@@ -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<string, string> = {
"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",
@@ -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,
@@ -218,6 +218,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
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<WidgetEditModalProps<WidgetKind>>(({
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
onOpenNewIntegration={handleOpenNewIntegration}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
/>
)}