fix: address board grid review

This commit is contained in:
Thomas Camlong committed 2026-08-01 23:26:02 +02:00
1 parent d94cadd3e7
commit d0c7ba401f
54 files changed
+657 -323

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# Glossary
In the glossary, you'll learn the different components and their names of Homarr.
This glossary explains Homarr's main components and the terms used for them.
| Term | Definition |
| ----------- | --------------------------------------------------------------------------------------- |
@@ -155,6 +155,7 @@ Resize tiles from any edge or corner handle.
On a touch screen, press and hold the tile before moving it; resize handles support direct touch input.
Keyboard users can focus a tile, press Enter or Space to start editing, use the arrow keys to move it, and use Shift plus the arrow keys to resize it.
Press Enter, Space, or Escape to stop keyboard editing.
Keyboard moves and resizes apply immediately; Escape stops keyboard editing without undoing those changes.
During a pointer or touch interaction, Escape cancels the move or resize and restores the layout from before that interaction.
The **Move / resize item** action in the edit menu remains available for precise numeric input.
@@ -9,8 +9,9 @@ import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@ho
import { useI18n } from "@homarr/translation/client";
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount, getLayoutRowCount } from "../layout";
import { getLayoutRowCount } from "../layout";
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
@@ -117,7 +118,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
w: width,
h: height,
};
const targetPlacements = getSectionPlacements(board, currentLayoutId, target.id).filter(
const targetPlacements = getSectionGridPlacements(board, currentLayoutId, target.id).filter(
(placement) => placement.id !== entry.id,
);
const resolvedTarget = resolvePinnedGridCollisions(
@@ -137,7 +138,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
{
layoutId: currentLayoutId,
sectionId: sourceSectionId,
placements: getSectionPlacements(board, currentLayoutId, sourceSectionId).filter(
placements: getSectionGridPlacements(board, currentLayoutId, sourceSectionId).filter(
(placement) => placement.id !== entry.id,
),
},
@@ -284,52 +285,15 @@ export const getMoveTargets = (
}));
};
const getSectionPlacements = (board: Board, layoutId: string, sectionId: string): SectionGridPlacement[] => [
...board.items.flatMap((item) => {
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout?.sectionId === sectionId
? [
{
id: item.id,
type: "item" as const,
x: layout.xOffset,
y: layout.yOffset,
w: layout.width,
h: layout.height,
},
]
: [];
}),
...board.sections.flatMap((section) => {
if (section.kind !== "container") return [];
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout?.parentSectionId === sectionId
? [
{
id: section.id,
type: "section" as const,
x: layout.xOffset,
y: layout.yOffset,
w: layout.width,
h: layout.height,
},
]
: [];
}),
];
const getSectionColumnCount = (board: Board, layoutId: string, sectionId: string) => {
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
if (!layout) throw new Error(`Layout "${layoutId}" was not found`);
const section = board.sections.find((candidate) => candidate.id === sectionId);
if (!section) throw new Error(`Section "${sectionId}" was not found`);
if (section.kind === "container") {
const sectionLayout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
if (!sectionLayout) throw new Error(`Section "${sectionId}" has no current layout`);
return sectionLayout.width;
}
return getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset));
const columnCount = getSectionGridColumnCount(board, layoutId, sectionId);
if (columnCount === null) throw new Error(`Section "${sectionId}" has no current layout`);
return columnCount;
};
const getSectionMaxRowCount = (board: Board, layoutId: string, sectionId: string) => {
@@ -343,7 +307,7 @@ const getSectionMaxRowCount = (board: Board, layoutId: string, sectionId: string
const getEntryMinimumSize = (board: Board, layoutId: string, entry: MovableEntry) => {
if (entry.type === "item") return { width: 1, height: 1 };
const children = getSectionPlacements(board, layoutId, entry.id);
const children = getSectionGridPlacements(board, layoutId, entry.id);
return {
width: Math.max(1, ...children.map((child) => child.x + child.w)),
height: Math.max(1, ...children.map((child) => child.y + child.h)),
@@ -58,7 +58,15 @@ describe("container actions", () => {
const nestedItem = new ItemMockBuilder({ id: "nested-item" })
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1, width: 1, height: 1 })
.build();
const board = new BoardMockBuilder().addSection(removed).addSection(child).addItem(nestedItem).build();
const board = new BoardMockBuilder()
.addEmptySection({ id: "root" })
.addSection(removed)
.addSection(child)
.addItem(nestedItem)
.build();
const layout = board.layouts[0];
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
const result = removeContainerCallback({ id: removed.id })(board);
const flattenedChild = result.sections.find(
@@ -78,7 +86,7 @@ describe("container actions", () => {
});
});
test("normalizes and resolves collisions while flattening malformed child geometry", () => {
test("normalizes, pins the top-left child, and resolves collisions while flattening malformed geometry", () => {
const removed = new ContainerSectionMockBuilder({ id: "removed" })
.addLayout({ layoutId: "layout", parentSectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 2 })
.build();
@@ -111,7 +119,67 @@ describe("container actions", () => {
);
expect(layouts.existing).toMatchObject({ xOffset: 0, yOffset: 0, width: 2, height: 1 });
expect(layouts.a).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 1 });
expect(layouts.b).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 1, width: 2, height: 1 });
expect(layouts.a).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 1, width: 2, height: 1 });
expect(layouts.b).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 1 });
});
test("keeps the container when a child container has layout geometry that cannot be flattened", () => {
const removed = new ContainerSectionMockBuilder({ id: "removed" })
.addLayout({ layoutId: "other-layout", parentSectionId: "root", xOffset: 3, yOffset: 4 })
.build();
const child = new ContainerSectionMockBuilder({ id: "child" })
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2 })
.build();
const board = new BoardMockBuilder().addSection(removed).addSection(child).build();
const result = removeContainerCallback({ id: removed.id })(board);
expect(result).toBe(board);
expect(result.sections).toContainEqual(removed);
});
test("keeps the container when a child item has layout geometry that cannot be flattened", () => {
const removed = new ContainerSectionMockBuilder({ id: "removed" })
.addLayout({ layoutId: "other-layout", parentSectionId: "root", xOffset: 3, yOffset: 4 })
.build();
const item = new ItemMockBuilder({ id: "item" })
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1 })
.build();
const board = new BoardMockBuilder().addSection(removed).addItem(item).build();
const result = removeContainerCallback({ id: removed.id })(board);
expect(result).toBe(board);
expect(result.sections).toContainEqual(removed);
});
test("keeps the container when flattening would move a child back into the removed container", () => {
const removed = new ContainerSectionMockBuilder({ id: "removed" })
.addLayout({ layoutId: "layout", parentSectionId: "removed", xOffset: 3, yOffset: 4 })
.build();
const item = new ItemMockBuilder({ id: "item" })
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1 })
.build();
const board = new BoardMockBuilder().addSection(removed).addItem(item).build();
const result = removeContainerCallback({ id: removed.id })(board);
expect(result).toBe(board);
expect(result.sections).toContainEqual(removed);
});
test("keeps the container when its destination is a child container", () => {
const removed = new ContainerSectionMockBuilder({ id: "removed" })
.addLayout({ layoutId: "layout", parentSectionId: "child", xOffset: 3, yOffset: 4 })
.build();
const child = new ContainerSectionMockBuilder({ id: "child" })
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2 })
.build();
const board = new BoardMockBuilder().addSection(removed).addSection(child).build();
const result = removeContainerCallback({ id: removed.id })(board);
expect(result).toBe(board);
expect(result.sections).toContainEqual(removed);
});
});
@@ -1,7 +1,9 @@
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import type { Board, ContainerSection } from "~/app/[locale]/boards/_types";
import { resolvePinnedGridCollisions } from "~/components/board/sections/grid/dnd";
import {
getSectionGridColumnCount,
getSectionGridPlacements,
} from "~/components/board/sections/grid/section-grid-placements";
import type { SectionGridPlacement } from "~/components/board/sections/grid/use-grid-layout-actions";
export interface RemoveContainerInput {
@@ -16,6 +18,27 @@ export const removeContainerCallback =
);
if (!sectionToRemove) return board;
const removedLayoutsById = new Map(sectionToRemove.layouts.map((layout) => [layout.layoutId, layout]));
const childLayoutIds = [
...board.sections.flatMap((section) =>
section.kind === "container"
? section.layouts.flatMap((layout) =>
layout.parentSectionId === sectionToRemove.id ? [layout.layoutId] : [],
)
: [],
),
...board.items.flatMap((item) =>
item.layouts.flatMap((layout) => (layout.sectionId === sectionToRemove.id ? [layout.layoutId] : [])),
),
];
const hasUnresolvableChildLayout = childLayoutIds.some((layoutId) => {
const removedLayout = removedLayoutsById.get(layoutId);
return (
!removedLayout || !isValidFlattenDestination(board, sectionToRemove.id, layoutId, removedLayout.parentSectionId)
);
});
if (hasUnresolvableChildLayout) return board;
const flattened = {
...board,
sections: board.sections
@@ -31,7 +54,7 @@ export const removeContainerCallback =
const removedSectionLayout = sectionToRemove.layouts.find(
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
);
if (!removedSectionLayout) throw new Error("Layout not found");
if (!removedSectionLayout) return layout;
return {
...layout,
@@ -50,7 +73,7 @@ export const removeContainerCallback =
const removedSectionLayout = sectionToRemove.layouts.find(
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
);
if (!removedSectionLayout) throw new Error("Layout not found");
if (!removedSectionLayout) return layout;
return {
...layout,
@@ -65,6 +88,29 @@ export const removeContainerCallback =
return resolveFlattenedLayouts(flattened, sectionToRemove, board);
};
const isValidFlattenDestination = (
board: Board,
removedSectionId: string,
layoutId: string,
destinationSectionId: string,
) => {
const visited = new Set<string>();
let sectionId = destinationSectionId;
while (true) {
if (sectionId === removedSectionId || visited.has(sectionId)) return false;
visited.add(sectionId);
const section = board.sections.find((candidate) => candidate.id === sectionId);
if (!section) return false;
if (section.kind !== "container") return true;
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) return false;
sectionId = layout.parentSectionId;
}
};
const resolveFlattenedLayouts = (board: Board, removed: ContainerSection, original: Board): Board => {
const resolvedByLayoutAndId = new Map<string, SectionGridPlacement>();
@@ -80,13 +126,13 @@ const resolveFlattenedLayouts = (board: Board, removed: ContainerSection, origin
return layout?.parentSectionId === removed.id ? [section.id] : [];
}),
];
const pinnedId = movedIds.toSorted()[0];
if (!pinnedId) continue;
const columnCount = getParentColumnCount(board, removedLayout.layoutId, removedLayout.parentSectionId);
const columnCount = getSectionGridColumnCount(board, removedLayout.layoutId, removedLayout.parentSectionId);
if (!columnCount) continue;
const placements = getSectionPlacements(board, removedLayout.layoutId, removedLayout.parentSectionId);
const pinned = placements.find((placement) => placement.id === pinnedId);
const placements = getSectionGridPlacements(board, removedLayout.layoutId, removedLayout.parentSectionId);
const movedIdSet = new Set(movedIds);
const pinned = placements
.filter((placement) => movedIdSet.has(placement.id))
.toSorted((first, second) => first.y - second.y || first.x - second.x || first.id.localeCompare(second.id))[0];
if (!pinned) continue;
for (const placement of resolvePinnedGridCollisions(placements, pinned, columnCount)) {
@@ -133,47 +179,3 @@ const resolveFlattenedLayouts = (board: Board, removed: ContainerSection, origin
),
};
};
const getSectionPlacements = (board: Board, layoutId: string, sectionId: string): SectionGridPlacement[] => [
...board.items.flatMap((item) => {
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout?.sectionId === sectionId
? [
{
id: item.id,
type: "item" as const,
x: layout.xOffset,
y: layout.yOffset,
w: layout.width,
h: layout.height,
},
]
: [];
}),
...board.sections.flatMap((section) => {
if (section.kind !== "container") return [];
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout?.parentSectionId === sectionId
? [
{
id: section.id,
type: "section" as const,
x: layout.xOffset,
y: layout.yOffset,
w: layout.width,
h: layout.height,
},
]
: [];
}),
];
const getParentColumnCount = (board: Board, layoutId: string, sectionId: string) => {
const section = board.sections.find((candidate) => candidate.id === sectionId);
if (!section) return null;
if (section.kind === "container") {
return section.layouts.find((layout) => layout.layoutId === layoutId)?.width ?? null;
}
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
return layout ? getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset)) : null;
};
@@ -1,8 +1,7 @@
import { useCallback } from "react";
import type { z } from "zod/v4";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { containerSectionOptionsSchema } from "@homarr/validation/shared";
import type { ContainerSectionOptions } from "@homarr/validation/shared";
import { addContainerCallback } from "./actions/add-container";
import type { RemoveContainerInput } from "./actions/remove-container";
@@ -10,7 +9,7 @@ import { removeContainerCallback } from "./actions/remove-container";
interface UpdateContainerOptions {
containerId: string;
newOptions: z.infer<typeof containerSectionOptionsSchema>;
newOptions: ContainerSectionOptions;
}
export const useContainerActions = () => {
@@ -18,6 +18,7 @@ import {
import type { GridPlacement } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd";
import type { GridTransaction } from "./dnd";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions";
@@ -109,19 +110,29 @@ export const FixedGridItem = ({
const current = placements.find((candidate) => candidate.id === item.id);
if (!current) return;
const transaction = beginGridTransaction(
{
grids: [
{
id: section.id,
columnCount,
maxRowCount,
placements,
},
],
},
{ activeId: current.id, sourceGridId: section.id },
);
let transaction: GridTransaction<SectionGridPlacement>;
try {
transaction = beginGridTransaction(
{
grids: [
{
id: section.id,
columnCount,
maxRowCount,
placements,
},
],
},
{ activeId: current.id, sourceGridId: section.id },
);
} catch {
keyboardGridRef.current = {
source: renderedPlacements,
current: renderedPlacements,
};
setKeyboardEditing(false);
return;
}
const delta = getKeyboardDelta(event.key);
const result = event.shiftKey
? previewGridResize(transaction, {
@@ -0,0 +1,14 @@
export const getGridDepth = (gridId: string, parentGridById: ReadonlyMap<string, string | null>) => {
let depth = 0;
let currentGridId = gridId;
const visited = new Set([gridId]);
while (true) {
const parentGridId = parentGridById.get(currentGridId);
if (!parentGridId || visited.has(parentGridId)) return depth;
depth += 1;
visited.add(parentGridId);
currentGridId = parentGridId;
}
};
@@ -5,7 +5,7 @@
background-image:
linear-gradient(to right, color-mix(in srgb, var(--mantine-color-dimmed) 18%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in srgb, var(--mantine-color-dimmed) 18%, transparent) 1px, transparent 1px);
background-size: 224px 224px;
background-size: var(--board-grid-pitch) var(--board-grid-pitch);
border-radius: var(--mantine-radius-md);
}
@@ -21,10 +21,15 @@ import type {
} from "@dnd-kit/react";
import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDroppable } from "@dnd-kit/react";
import { useCurrentLayout } from "@homarr/boards/context";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize } from "~/components/board/layout";
import {
getLayoutRowCount,
getLogicalItemStyle,
getLogicalTrackSize,
LOGICAL_GRID_PITCH,
} from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import {
@@ -38,6 +43,7 @@ import {
previewGridResize,
} from "./dnd";
import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, TransactionalGridState } from "./dnd";
import { getGridDepth } from "./grid-depth";
import { GridResizePreviewProvider } from "./grid-editor-runtime";
import { useBoardGridPortalHost } from "./grid-portal-host";
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
@@ -119,6 +125,7 @@ interface ResizePreviewInput {
interface BoardGridEditorContextValue {
interaction: GridInteraction | null;
registerGrid: (grid: RegisteredGrid) => () => void;
getDepth: (gridId: string) => number;
isDropTargetEligible: (source: AbstractDraggable, targetGridId: string) => boolean;
canAcceptDrop: (source: AbstractDraggable, targetGridId: string) => boolean;
beginResize: (input: ResizeStartInput) => boolean;
@@ -137,6 +144,7 @@ const useBoardGridEditor = () => {
/** One provider coordinates every root and nested grid on the board. */
export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const t = useI18n();
const { announce } = useBoardGridPortalHost();
@@ -147,6 +155,19 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
const collisionFrameRef = useRef(0);
const [interaction, setInteraction] = useState<GridInteraction | null>(null);
const [overlayZoom, setOverlayZoom] = useState(1);
const parentGridById = useMemo(
() =>
new Map<string, string | null>(
board.sections.map((section): [string, string | null] => [
section.id,
section.kind === "container"
? (section.layouts.find((layout) => layout.layoutId === currentLayoutId)?.parentSectionId ?? null)
: null,
]),
),
[board.sections, currentLayoutId],
);
const getDepth = useCallback((gridId: string) => getGridDepth(gridId, parentGridById), [parentGridById]);
const clearInteraction = useCallback(() => {
window.cancelAnimationFrame(collisionFrameRef.current);
@@ -512,11 +533,8 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
arePointerCoordinatesEqual(active.lastProcessedPointer, pointer);
if (canCommitRenderedPreview) {
const activeAfterHitTest = activeRef.current;
if (activeAfterHitTest?.valid && activeAfterHitTest.targetGridId) {
commitActiveTransaction(renderedInteraction.state);
return;
}
commitActiveTransaction(renderedInteraction.state);
return;
}
updateDragPreview(
@@ -596,6 +614,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
() => ({
interaction,
registerGrid,
getDepth,
isDropTargetEligible,
canAcceptDrop,
beginResize,
@@ -611,6 +630,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
interaction,
isDropTargetEligible,
previewResize,
getDepth,
registerGrid,
],
);
@@ -671,12 +691,13 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
const GridCollisionSynchronizer = ({ interaction }: { interaction: GridInteraction | null }) => {
const manager = useDragDropManager();
const geometryKey = interaction ? getGridGeometryKey(interaction.state) : null;
useLayoutEffect(() => {
if (!interaction || !manager) return;
if (!geometryKey || !manager) return;
for (const target of manager.registry.droppables.value) target.refreshShape();
manager.collisionObserver.forceUpdate(true);
}, [interaction, manager]);
}, [geometryKey, manager]);
return null;
};
@@ -692,11 +713,11 @@ export default function GridEditor({
}: GridEditorProps) {
const t = useI18n();
const { section, items, innerSections } = useSectionContext();
const { interaction, registerGrid, isDropTargetEligible } = useBoardGridEditor();
const { interaction, registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditor();
const gridRef = useRef<HTMLDivElement>(null);
const parentGridId = "parentSectionId" in section ? section.parentSectionId : null;
const ownerPlacementId = "parentSectionId" in section ? section.id : null;
const depth = getGridDepth(sectionId, parentGridId);
const depth = getDepth(sectionId);
const previewGrid = interaction?.state.grids.find((grid) => grid.id === sectionId);
const controlledById = useMemo(() => new Map(placements.map((placement) => [placement.id, placement])), [placements]);
const renderPlacements = useMemo(() => {
@@ -792,7 +813,13 @@ export default function GridEditor({
data-dnd-drop-target={isDropTarget ? "true" : "false"}
data-dnd-drop-valid={isDropTarget ? String(targetPlacement !== null) : undefined}
data-dnd-preview-revision={interaction?.targetGridId === sectionId ? interaction.previewRevision : undefined}
style={{ width: getLogicalTrackSize(columnCount), height: getLogicalTrackSize(renderedRowCount) }}
style={
{
width: getLogicalTrackSize(columnCount),
height: getLogicalTrackSize(renderedRowCount),
"--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`,
} as CSSProperties
}
>
{renderPlacements.map((placement) => {
const entry = entryById.get(placement.id);
@@ -1123,7 +1150,7 @@ const getPointerTargetGrid = (
grids: Iterable<RegisteredGrid>,
source: AbstractDraggable,
pointer: { x: number; y: number },
canAcceptDrop: (source: AbstractDraggable, targetGridId: string) => boolean,
isTargetEligible: (source: AbstractDraggable, targetGridId: string) => boolean,
) =>
Array.from(grids)
.filter((grid) => {
@@ -1135,7 +1162,7 @@ const getPointerTargetGrid = (
pointer.x <= rectangle.right &&
pointer.y >= rectangle.top &&
pointer.y <= rectangle.bottom &&
canAcceptDrop(source, grid.id)
isTargetEligible(source, grid.id)
);
})
.toSorted((first, second) => {
@@ -1217,9 +1244,17 @@ const getGridTargetData = (target: { data: Record<string, unknown> }): GridTarge
return data.kind === GRID_TARGET_TYPE && data.sectionId ? (data as GridTargetData) : null;
};
const getGridDepth = (sectionId: string, parentGridId: string | null) => {
if (!parentGridId || sectionId === parentGridId) return 0;
const parent = document.querySelector<HTMLElement>(`[data-grid-section-id="${CSS.escape(parentGridId)}"]`);
const parentDepth = Number(parent?.dataset.gridDepth ?? 0);
return Number.isFinite(parentDepth) ? parentDepth + 1 : 1;
};
const getGridGeometryKey = (state: TransactionalGridState<SectionGridPlacement>) =>
JSON.stringify(
state.grids
.map((grid) => ({
id: grid.id,
columnCount: grid.columnCount,
maxRowCount: grid.maxRowCount,
parentGridId: grid.parentGridId,
placements: grid.placements
.map(({ id, x, y, w, h }) => ({ id, x, y, w, h }))
.toSorted((first, second) => first.id.localeCompare(second.id)),
}))
.toSorted((first, second) => first.id.localeCompare(second.id)),
);
@@ -1,7 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { RouterOutputs } from "@homarr/api";
@@ -33,6 +33,7 @@ const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(n
* content.
*/
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
const integrations = useIntegrations();
const containersRef = useRef<Map<string, HTMLElement>>(new Map());
const [containers, setContainers] = useState<Map<string, HTMLElement>>(() => new Map());
@@ -40,6 +41,10 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
const announce = useCallback((message: string) => {
setAnnouncement((previous) => ({ id: previous.id + 1, message }));
}, []);
const liveEntryIds = useMemo(
() => new Set([...board.items.map((item) => item.id), ...board.sections.map((section) => section.id)]),
[board.items, board.sections],
);
const acquireContainer = useCallback((id: string) => {
const existing = containersRef.current.get(id);
@@ -53,9 +58,17 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
return container;
}, []);
// Keep acquired nodes for the host's lifetime. Cross-grid commits can
// transiently render neither shell, and detaching the node must not remount
// live widget state before the destination shell adopts it.
useEffect(() => {
// A cross-grid move can briefly have no shell, but the entry still exists
// on the board. Only remove containers whose board entity was deleted.
let didPrune = false;
for (const id of containersRef.current.keys()) {
if (liveEntryIds.has(id)) continue;
containersRef.current.delete(id);
didPrune = true;
}
if (didPrune) setContainers(new Map(containersRef.current));
}, [liveEntryIds]);
const value = useMemo<GridPortalHostContextValue>(
() => ({
@@ -0,0 +1,49 @@
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import type { Board } from "~/app/[locale]/boards/_types";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
export const getSectionGridPlacements = (board: Board, layoutId: string, sectionId: string): SectionGridPlacement[] => [
...board.items.flatMap((item) => {
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout?.sectionId === sectionId
? [
{
id: item.id,
type: "item" as const,
x: layout.xOffset,
y: layout.yOffset,
w: layout.width,
h: layout.height,
},
]
: [];
}),
...board.sections.flatMap((section) => {
if (section.kind !== "container") return [];
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout?.parentSectionId === sectionId
? [
{
id: section.id,
type: "section" as const,
x: layout.xOffset,
y: layout.yOffset,
w: layout.width,
h: layout.height,
},
]
: [];
}),
];
export const getSectionGridColumnCount = (board: Board, layoutId: string, sectionId: string): number | null => {
const section = board.sections.find((candidate) => candidate.id === sectionId);
if (!section) return null;
if (section.kind === "container") {
return section.layouts.find((layout) => layout.layoutId === layoutId)?.width ?? null;
}
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
return layout ? getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset)) : null;
};
@@ -92,6 +92,8 @@ describe("fixed grid item behavior", () => {
mocks.editMode = true;
mocks.maxRowCount = null;
getWeatherMock().advancedOptions.title = null;
getClockMock().xOffset = 1;
getClockMock().yOffset = 0;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
@@ -200,6 +202,21 @@ describe("fixed grid item behavior", () => {
});
});
test("stops keyboard editing when the rendered grid state is invalid", () => {
getClockMock().xOffset = 0;
renderWeather(root);
const entry = getEditorEntry(container);
startKeyboardEditing(entry);
act(() => entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
expect(entry.dataset.keyboardEditing).toBe("false");
expect(mocks.commitSectionGrid).not.toHaveBeenCalled();
expect(mocks.announce).toHaveBeenLastCalledWith(
"widget.weather.name, column 1, row 1: item.moveResize.keyboard.stopped",
);
});
test("uses the whole tile as the focusable move surface with a visual drag affordance", () => {
getWeatherMock().advancedOptions.title = " Balcony forecast ";
renderWeather(root);
@@ -286,6 +303,12 @@ const getWeatherMock = () => {
return item;
};
const getClockMock = () => {
const item = mocks.items[1];
if (!item) throw new Error("Expected the clock item mock to exist");
return item;
};
const getEditorEntry = (container: HTMLElement) => {
const entry = container.querySelector<HTMLDivElement>("[data-editor-grid-entry]");
if (!entry) throw new Error("Expected the fixed grid item to render an editor entry");
@@ -0,0 +1,26 @@
import { describe, expect, test } from "vitest";
import { getGridDepth } from "../grid-depth";
describe("grid depth", () => {
test("follows the registered parent chain for deeply nested grids", () => {
const parents = new Map<string, string | null>([
["root", null],
["first", "root"],
["second", "first"],
["third", "second"],
]);
expect(getGridDepth("root", parents)).toBe(0);
expect(getGridDepth("third", parents)).toBe(3);
});
test("stops safely when malformed parent data contains a cycle", () => {
const parents = new Map<string, string | null>([
["first", "second"],
["second", "first"],
]);
expect(getGridDepth("first", parents)).toBe(1);
});
});
@@ -12,7 +12,7 @@ describe("board grid import boundary", () => {
test("keeps every current dnd-kit runtime import inside the edit-only module", () => {
const runtimeImports = getSourceFiles(nextjsSourceDirectory)
.flatMap(getRuntimeImports)
.filter(({ specifier }) => CURRENT_DND_KIT_PACKAGES.has(specifier))
.filter(({ specifier }) => isCurrentDndKitImport(specifier))
.map(({ filePath, kind, specifier }) => ({
file: toSourceRelativePath(filePath),
kind,
@@ -40,6 +40,11 @@ describe("board grid import boundary", () => {
kind: "static",
specifier: "@dnd-kit/dom",
},
{
file: "components/board/sections/grid/grid-editor.tsx",
kind: "static",
specifier: "@dnd-kit/dom/utilities",
},
{
file: "components/board/sections/grid/grid-editor.tsx",
kind: "static",
@@ -104,7 +109,10 @@ describe("board grid import boundary", () => {
});
});
const CURRENT_DND_KIT_PACKAGES = new Set(["@dnd-kit/abstract", "@dnd-kit/collision", "@dnd-kit/dom", "@dnd-kit/react"]);
const CURRENT_DND_KIT_PACKAGES = ["@dnd-kit/abstract", "@dnd-kit/collision", "@dnd-kit/dom", "@dnd-kit/react"];
const isCurrentDndKitImport = (specifier: string) =>
CURRENT_DND_KIT_PACKAGES.some((packageName) => specifier === packageName || specifier.startsWith(`${packageName}/`));
interface RuntimeImport {
filePath: string;
@@ -0,0 +1,90 @@
import { act, createElement, useLayoutEffect } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BoardGridPortalHost, useBoardGridPortalHost } from "../grid-portal-host";
const mocks = vi.hoisted(() => ({
board: {
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "root" }] }],
sections: [],
},
}));
vi.mock("@homarr/auth/client", () => ({
useIntegrations: () => undefined,
}));
vi.mock("@homarr/boards/context", () => ({
useCurrentLayout: () => "desktop",
useRequiredBoard: () => mocks.board,
}));
describe("board grid portal host", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
mocks.board = {
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "root" }] }],
sections: [],
};
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
test("keeps a portal across relocation and prunes it after permanent deletion", () => {
const acquired: HTMLElement[] = [];
renderHost(root, true, 0, acquired);
expect(getContainerCount(container)).toBe("1");
mocks.board = {
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "other" }] }],
sections: [],
};
renderHost(root, true, 1, acquired);
expect(getContainerCount(container)).toBe("1");
expect(new Set(acquired).size).toBe(1);
mocks.board = { items: [], sections: [] };
renderHost(root, false, 2, acquired);
expect(getContainerCount(container)).toBe("0");
});
});
const renderHost = (root: Root, acquire: boolean, revision: number, acquired: HTMLElement[]) => {
act(() => {
root.render(
createElement(BoardGridPortalHost, null, createElement(PortalRegistryProbe, { acquire, revision, acquired })),
);
});
};
const PortalRegistryProbe = ({
acquire,
revision,
acquired,
}: {
acquire: boolean;
revision: number;
acquired: HTMLElement[];
}) => {
const portalHost = useBoardGridPortalHost();
useLayoutEffect(() => {
if (acquire) acquired.push(portalHost.acquireContainer("weather"));
}, [acquire, acquired, portalHost, revision]);
return createElement("span", { "data-container-count": String(portalHost.containers.size) });
};
const getContainerCount = (container: HTMLElement) =>
container.querySelector<HTMLElement>("[data-container-count]")?.dataset.containerCount;