fix: stabilize responsive layout editing

This commit is contained in:
ajnart committed 2026-08-08 18:02:39 +02:00
1 parent 1877b4f9a4
commit a9fc3c6faa
14 files changed
+168 -60

No files matched your search

@@ -1,7 +1,7 @@
import { useCallback } from "react";
import { fetchApi } from "@homarr/api/client";
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
@@ -18,6 +18,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
const t = useI18n();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const createCategoryAtYOffset = useCallback(
(position: number) => {
@@ -104,7 +105,6 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const settings = useSettings();
const openAllInNewTabs = useCallback(async () => {
const currentLayoutId = getCurrentLayout(board);
const appIds = (
await filterByItemKind(
board.items.filter(
@@ -132,7 +132,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
});
break;
}
}, [category, board, t, openConfirmModal, settings]);
}, [category, board, currentLayoutId, t, openConfirmModal, settings]);
return {
addCategoryAbove,
@@ -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 !== "dynamic") 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 !== "dynamic") 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;
};