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

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

No files matched your search

@@ -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<string>;
toggleSelectItem: (id: string) => void;
clearSelection: () => void;
isSelected: (id: string) => boolean;
removeSelectedItems: () => void;
moveSelectedItemsToSection: (sectionId: string) => void;
}
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(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: (
<Group justify="space-between" wrap="nowrap">
<Text size="sm">{tSelection("removed", { count })}</Text>
<Button
variant="subtle"
size="compact-xs"
onClick={() => {
persistBoard((previous) => ({
...previous,
items: [...previous.items, ...removedItems],
}));
notifications.hide(notificationId);
}}
>
{t("common.action.undo")}
</Button>
</Group>
),
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<string, ItemLayout>();
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 <BoardSelectionContext.Provider value={value}>{children}</BoardSelectionContext.Provider>;
};
export const useBoardSelection = () => {
const context = useContext(BoardSelectionContext);
if (!context) {
throw new Error("useBoardSelection must be used within a BoardSelectionProvider");
}
return context;
};
@@ -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 (
<Paper
withBorder
shadow="xl"
radius="xl"
p="xs"
bg="light-dark(var(--mantine-color-body), var(--mantine-color-dark-7))"
style={{
position: "fixed",
bottom: 28,
left: "50%",
transform: "translateX(-50%)",
zIndex: 1000,
width: "max-content",
maxWidth: "calc(100vw - 1rem)",
boxShadow: "0 12px 40px rgba(0, 0, 0, 0.28)",
backdropFilter: "blur(14px)",
border: "1px solid var(--mantine-color-default-border)",
animation: "slideUp 200ms cubic-bezier(0.4, 0, 0.2, 1) forwards",
}}
>
<Group gap="xs" wrap="nowrap">
{/* Avatar Group with HoverCard Popover showing all items */}
<HoverCard position="top" withArrow shadow="xl" radius="md" openDelay={100} closeDelay={200} withinPortal>
<HoverCard.Target>
<UnstyledButton
display="inline-flex"
aria-label={tSelection("summary", { count: sortedSelectedItems.length })}
style={{ alignItems: "center", borderRadius: "var(--mantine-radius-xl)" }}
>
<Avatar.Group>
{visibleItems.map((item) => {
const { displayName, iconUrl, IconComponent } = getItemDetails(item);
return (
<Avatar
key={item.id}
src={iconUrl}
radius="xl"
size={32}
color="primaryColor"
variant="light"
styles={{ image: { objectFit: "contain", padding: 2 } }}
alt={displayName}
>
{!iconUrl && <IconComponent size={16} />}
</Avatar>
);
})}
{overflowCount > 0 && (
<Avatar radius="xl" size={32} color="gray" variant="filled">
+{overflowCount}
</Avatar>
)}
</Avatar.Group>
</UnstyledButton>
</HoverCard.Target>
<HoverCard.Dropdown p={6} style={{ width: 220 }}>
<Stack gap={4}>
<Text size="10px" fw={700} c="dimmed" tt="uppercase" px={4}>
{tSelection("selected", { count: sortedSelectedItems.length })}
</Text>
<ScrollArea.Autosize mah={176} type="auto">
<Stack gap={2}>
{sortedSelectedItems.map((item) => {
const { displayName, iconUrl, IconComponent } = getItemDetails(item);
return (
<Group key={item.id} gap={6} wrap="nowrap" px={4} py={2}>
<Avatar
src={iconUrl}
radius="sm"
size={22}
color="primaryColor"
variant="light"
styles={{ image: { objectFit: "contain", padding: 2 } }}
alt={displayName}
>
{!iconUrl && <IconComponent size={12} />}
</Avatar>
<Text size="xs" fw={500} truncate>
{displayName}
</Text>
</Group>
);
})}
</Stack>
</ScrollArea.Autosize>
</Stack>
</HoverCard.Dropdown>
</HoverCard>
<Text size="xs" c="dimmed" visibleFrom="sm" maw={220} lh={1.25}>
{t("app.action.select.multiSelectTip")}
</Text>
<Divider orientation="vertical" />
{/* Actions */}
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
{placementOptions.length > 1 && (
<Menu position="top" withArrow shadow="md">
<Menu.Target>
<Button size="xs" variant="light" color="primaryColor" leftSection={<IconFolderShare size={15} />}>
{tSelection("move")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{tSelection("moveTo")}</Menu.Label>
{placementOptions.map((opt) => (
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}>
{opt.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
<Button
size="xs"
variant="light"
color="red"
leftSection={<IconTrash size={15} />}
onClick={removeSelectedItems}
>
{tSelection("delete")}
</Button>
<Tooltip label={tSelection("clearShortcut")} withArrow>
<ActionIcon
size="sm"
variant="subtle"
color="gray"
onClick={clearSelection}
aria-label={tSelection("clear")}
>
<IconX size={14} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
);
};
@@ -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<typeof useBoardSelection>) => void }) => {
const selection = useBoardSelection();
onReady(selection);
return <div>Selection Ready</div>;
};
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<typeof useBoardSelection>;
await act(async () => {
root.render(
<MantineProvider>
<BoardSelectionProvider>
<SelectionConsumer
onReady={(ctx) => {
selectionContext = ctx;
}}
/>
</BoardSelectionProvider>
</MantineProvider>,
);
});
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<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");
});
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<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);
});
});