fix: stabilize responsive layout editing

This commit is contained in:
ajnart committed 2026-08-13 19:55:54 +02:00
1 parent e74d5e2aae
commit aef0a6d340
11 files changed
+135 -45

No files matched your search

@@ -4,6 +4,10 @@
background: color-mix(in srgb, var(--mantine-color-body) 82%, var(--mantine-primary-color-light));
padding: var(--mantine-spacing-xs);
min-height: 7rem;
max-height: min(24rem, 48dvh);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.section {
@@ -20,6 +20,18 @@ describe("layout representative widths", () => {
});
describe("projectBoardLayout", () => {
test("returns exact saved positions without recursively re-projecting the active layout", () => {
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
.addLayout({ layoutId: mobile.id, parentSectionId: rootSection.id, width: 3, height: 2 })
.build();
const board = new BoardMockBuilder().addLayout(mobile).addSections([rootSection, dynamicSection]).build();
expect(projectBoardLayout(board, mobile, mobile)).toEqual([
expect.objectContaining({ id: dynamicSection.id, sectionId: rootSection.id, width: 3, height: 2 }),
]);
});
test("uses production ordering and sizing for items and dynamic sections", () => {
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
@@ -24,6 +24,10 @@ export const projectBoardLayout = (
) => {
const elements = getElementsForLayout(board, sourceLayout.id);
if (sourceLayout.id === targetLayout.id && sourceLayout.columnCount === targetLayout.columnCount) {
return elements;
}
return board.sections
.filter((section) => section.kind !== "dynamic")
.flatMap(
@@ -1,9 +1,8 @@
import { getCurrentLayout } from "@homarr/boards/context";
import type { Board } from "~/app/[locale]/boards/_types";
export interface MoveAndResizeItemInput {
itemId: string;
layoutId: string;
xOffset: number;
yOffset: number;
width: number;
@@ -11,10 +10,8 @@ export interface MoveAndResizeItemInput {
}
export const moveAndResizeItemCallback =
({ itemId, ...layoutInput }: MoveAndResizeItemInput) =>
({ itemId, layoutId, ...layoutInput }: MoveAndResizeItemInput) =>
(previous: Board): Board => {
const currentLayout = getCurrentLayout(previous);
return {
...previous,
items: previous.items.map((item) =>
@@ -23,7 +20,7 @@ export const moveAndResizeItemCallback =
: {
...item,
layouts: item.layouts.map((layout) =>
layout.layoutId !== currentLayout
layout.layoutId !== layoutId
? layout
: {
...layout,
@@ -1,9 +1,8 @@
import { getCurrentLayout } from "@homarr/boards/context";
import type { Board } from "~/app/[locale]/boards/_types";
export interface MoveItemToSectionInput {
itemId: string;
layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
@@ -12,10 +11,8 @@ export interface MoveItemToSectionInput {
}
export const moveItemToSectionCallback =
({ itemId, ...layoutInput }: MoveItemToSectionInput) =>
({ itemId, layoutId, ...layoutInput }: MoveItemToSectionInput) =>
(board: Board): Board => {
const currentLayout = getCurrentLayout(board);
return {
...board,
items: board.items.map((item) =>
@@ -24,7 +21,7 @@ export const moveItemToSectionCallback =
: {
...item,
layouts: item.layouts.map((layout) =>
layout.layoutId !== currentLayout
layout.layoutId !== layoutId
? layout
: {
...layout,
@@ -1,6 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import { describe, expect, test } from "vitest";
import { moveAndResizeItemCallback } from "../move-and-resize-item";
import { BoardMockBuilder } from "./mocks/board-mock";
@@ -11,8 +9,6 @@ describe("moveItemToSectionCallback should move item in section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
xOffset: 20,
yOffset: 30,
@@ -25,7 +21,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
// Act
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(3);
@@ -43,8 +39,6 @@ describe("moveItemToSectionCallback should move item in section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
xOffset: 20,
yOffset: 30,
@@ -56,7 +50,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
// Act
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(2);
@@ -1,6 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import { describe, expect, test } from "vitest";
import { moveItemToSectionCallback } from "../move-item-to-section";
import { BoardMockBuilder } from "./mocks/board-mock";
@@ -11,8 +9,6 @@ describe("moveItemToSectionCallback should move item to section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
sectionId: "3",
xOffset: 20,
@@ -26,7 +22,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
// Act
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(3);
@@ -44,8 +40,6 @@ describe("moveItemToSectionCallback should move item to section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
sectionId: "3",
xOffset: 20,
@@ -58,7 +52,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
// Act
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(2);
@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { useCurrentLayout } from "@homarr/boards/context";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
@@ -31,6 +32,7 @@ interface UpdateItemIntegrations {
export const useItemActions = () => {
const { updateBoard } = useUpdateBoard();
const currentLayoutId = useCurrentLayout();
const createItem = useCallback((input: CreateItemInput) => updateBoard(createItemCallback(input)), [updateBoard]);
@@ -76,17 +78,17 @@ export const useItemActions = () => {
);
const moveAndResizeItem = useCallback(
(input: MoveAndResizeItemInput) => {
updateBoard(moveAndResizeItemCallback(input));
(input: Omit<MoveAndResizeItemInput, "layoutId">) => {
updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId }));
},
[updateBoard],
[currentLayoutId, updateBoard],
);
const moveItemToSection = useCallback(
(input: MoveItemToSectionInput) => {
updateBoard(moveItemToSectionCallback(input));
(input: Omit<MoveItemToSectionInput, "layoutId">) => {
updateBoard(moveItemToSectionCallback({ ...input, layoutId: currentLayoutId }));
},
[updateBoard],
[currentLayoutId, updateBoard],
);
const removeItem = useCallback(
@@ -0,0 +1,42 @@
import { describe, expect, test } from "vitest";
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock";
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
import { wouldCreateSectionCycle } from "./section-actions";
describe("wouldCreateSectionCycle", () => {
test("rejects moving a dynamic section into itself or one of its descendants", () => {
const layoutId = "layout";
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
const parentSection = new DynamicSectionMockBuilder({ id: "parent" })
.addLayout({ layoutId, parentSectionId: rootSection.id })
.build();
const childSection = new DynamicSectionMockBuilder({ id: "child" })
.addLayout({ layoutId, parentSectionId: parentSection.id })
.build();
const board = new BoardMockBuilder().addSections([rootSection, parentSection, childSection]).build();
expect(
wouldCreateSectionCycle(board, {
innerSectionId: parentSection.id,
sectionId: parentSection.id,
layoutId,
}),
).toBe(true);
expect(
wouldCreateSectionCycle(board, {
innerSectionId: parentSection.id,
sectionId: childSection.id,
layoutId,
}),
).toBe(true);
expect(
wouldCreateSectionCycle(board, {
innerSectionId: childSection.id,
sectionId: rootSection.id,
layoutId,
}),
).toBe(false);
});
});
@@ -1,8 +1,10 @@
import { useCallback } from "react";
import { getCurrentLayout } from "@homarr/boards/context";
import { useCurrentLayout } from "@homarr/boards/context";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { Board } from "~/app/[locale]/boards/_types";
interface MoveAndResizeInnerSection {
innerSectionId: string;
xOffset: number;
@@ -21,6 +23,7 @@ interface MoveInnerSectionToSection {
export const useSectionActions = () => {
const { updateBoard } = useUpdateBoard();
const currentLayoutId = useCurrentLayout();
const moveAndResizeInnerSection = useCallback(
({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
@@ -31,12 +34,10 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "container") return section;
const currentLayout = getCurrentLayout(previous);
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.layoutId !== currentLayout) return layout;
if (layout.layoutId !== currentLayoutId) return layout;
return {
...layout,
...positionProps,
@@ -46,12 +47,16 @@ export const useSectionActions = () => {
}),
}));
},
[updateBoard],
[currentLayoutId, updateBoard],
);
const moveInnerSectionToSection = useCallback(
({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
updateBoard((previous) => {
if (wouldCreateSectionCycle(previous, { innerSectionId, sectionId, layoutId: currentLayoutId })) {
return previous;
}
return {
...previous,
sections: previous.sections.map((section) => {
@@ -59,12 +64,10 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "container") return section;
const currentLayout = getCurrentLayout(previous);
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.layoutId !== currentLayout) return layout;
if (layout.layoutId !== currentLayoutId) return layout;
return {
...layout,
...positionProps,
@@ -76,7 +79,7 @@ export const useSectionActions = () => {
};
});
},
[updateBoard],
[currentLayoutId, updateBoard],
);
return {
@@ -84,3 +87,24 @@ export const useSectionActions = () => {
moveInnerSectionToSection,
};
};
export const wouldCreateSectionCycle = (
board: Board,
{ innerSectionId, sectionId, layoutId }: { innerSectionId: string; sectionId: string; layoutId: string },
) => {
const visitedSectionIds = new Set<string>();
let currentSectionId: string | null = sectionId;
while (currentSectionId) {
if (currentSectionId === innerSectionId) return true;
if (visitedSectionIds.has(currentSectionId)) return true;
visitedSectionIds.add(currentSectionId);
const currentSection = board.sections.find((section) => section.id === currentSectionId);
if (currentSection?.kind !== "dynamic") return false;
currentSectionId = currentSection.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId ?? null;
}
return false;
};
@@ -78,6 +78,26 @@ describe("Grid Algorithm", () => {
);
},
);
test("stops projecting a corrupted recursive section graph", () => {
const recursiveSection = algoItem({ id: "recursive", type: "section", width: 3 });
const duplicateRecursiveLayout = algoItem({
id: "recursive",
type: "section",
width: 3,
sectionId: "recursive",
});
const result = generateResponsiveGridFor({
items: [recursiveSection, duplicateRecursiveLayout],
width: 3,
previousWidth: 12,
sectionId: ROOT_SECTION_ID,
});
expect(result.items).toContainEqual(expect.objectContaining({ id: "recursive", sectionId: ROOT_SECTION_ID }));
expect(result.items).toHaveLength(2);
});
});
const algoItem = (item: Partial<GridAlgorithmItem>): GridAlgorithmItem => ({