chore: reconcile mobile layer after stack rebase

This commit is contained in:
ajnart committed 2026-08-14 11:32:09 +02:00
1 parent 4a4b04d471
commit eb918b805a
55 files changed
+1558 -598

No files matched your search

@@ -59,7 +59,7 @@
}
}
.dynamicTile {
.containerTile {
border-style: dashed;
background: color-mix(in srgb, var(--mantine-color-default) 88%, var(--mantine-primary-color-light));
}
@@ -1,14 +1,38 @@
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 { 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 { getRepresentativeLayoutWidth, projectBoardLayout } from "./_layout-utils";
const mobile = { id: "mobile", name: "Mobile", columnCount: 3, breakpoint: 0, role: "mobile" as const };
const custom = { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" as const };
const base = { id: "base", name: "Base", columnCount: 12, breakpoint: 768, role: "base" as const };
const mobile = {
id: "mobile",
name: "Mobile",
columnCount: 3,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
role: "mobile" as const,
};
const custom = {
id: "tablet",
name: "Tablet",
columnCount: 6,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 480,
role: "custom" as const,
};
const base = {
id: "base",
name: "Base",
columnCount: 12,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 768,
role: "base" as const,
};
describe("layout representative widths", () => {
test("uses a phone width, activation midpoint, and desktop minimum", () => {
@@ -22,30 +46,30 @@ 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" })
const container = new ContainerSectionMockBuilder({ id: "container" })
.addLayout({ layoutId: mobile.id, parentSectionId: rootSection.id, width: 3, height: 2 })
.build();
const board = new BoardMockBuilder().addLayout(mobile).addSections([rootSection, dynamicSection]).build();
const board = new BoardMockBuilder().addLayout(mobile).addSections([rootSection, container]).build();
expect(projectBoardLayout(board, mobile, mobile)).toEqual([
expect.objectContaining({ id: dynamicSection.id, sectionId: rootSection.id, width: 3, height: 2 }),
expect.objectContaining({ id: container.id, sectionId: rootSection.id, width: 3, height: 2 }),
]);
});
test("uses production ordering and sizing for items and dynamic sections", () => {
test("uses production ordering and sizing for items and containers", () => {
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
const container = new ContainerSectionMockBuilder({ id: "container" })
.addLayout({ layoutId: base.id, parentSectionId: rootSection.id, width: 6, height: 3, xOffset: 6, yOffset: 0 })
.build();
const rootItem = new ItemMockBuilder({ id: "root-item", kind: "clock" })
.addLayout({ layoutId: base.id, sectionId: rootSection.id, width: 5, height: 2, xOffset: 0, yOffset: 0 })
.build();
const nestedItem = new ItemMockBuilder({ id: "nested-item", kind: "clock" })
.addLayout({ layoutId: base.id, sectionId: dynamicSection.id, width: 4, height: 1, xOffset: 0, yOffset: 0 })
.addLayout({ layoutId: base.id, sectionId: container.id, width: 4, height: 1, xOffset: 0, yOffset: 0 })
.build();
const board = new BoardMockBuilder()
.addLayout(mobile)
.addSections([rootSection, dynamicSection])
.addSections([rootSection, container])
.addItems([rootItem, nestedItem])
.build();
@@ -59,7 +83,7 @@ describe("projectBoardLayout", () => {
yOffset: 0,
sectionId: rootSection.id,
});
expect(projected.find((element) => element.id === dynamicSection.id)).toMatchObject({
expect(projected.find((element) => element.id === container.id)).toMatchObject({
type: "section",
width: 3,
xOffset: 0,
@@ -69,7 +93,7 @@ describe("projectBoardLayout", () => {
expect(projected.find((element) => element.id === nestedItem.id)).toMatchObject({
type: "item",
width: 3,
sectionId: dynamicSection.id,
sectionId: container.id,
});
});
@@ -1,9 +1,12 @@
import { generateResponsiveGridFor } from "@homarr/common";
import type { GridAlgorithmItem } from "@homarr/common";
import type { BoardLane } from "@homarr/definitions";
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import type { Board } from "./_types";
export type BoardLayout = Board["layouts"][number];
type LayoutGeometry = Pick<BoardLayout, "id" | "columnCount" | "leftGutterColumnCount" | "rightGutterColumnCount">;
export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: BoardLayout[]) => {
if (layout.role === "mobile") return 390;
@@ -17,28 +20,69 @@ export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: Bo
return nextLayout ? Math.round((layout.breakpoint + nextLayout.breakpoint) / 2) : Math.max(1280, layout.breakpoint);
};
export const projectBoardLayout = (
board: Board,
sourceLayout: Pick<BoardLayout, "id" | "columnCount">,
targetLayout: Pick<BoardLayout, "id" | "columnCount">,
) => {
export const projectBoardLayout = (board: Board, sourceLayout: LayoutGeometry, targetLayout: LayoutGeometry) => {
const elements = getElementsForLayout(board, sourceLayout.id);
if (sourceLayout.id === targetLayout.id && sourceLayout.columnCount === targetLayout.columnCount) {
if (
sourceLayout.id === targetLayout.id &&
sourceLayout.columnCount === targetLayout.columnCount &&
sourceLayout.leftGutterColumnCount === targetLayout.leftGutterColumnCount &&
sourceLayout.rightGutterColumnCount === targetLayout.rightGutterColumnCount
) {
return elements;
}
return board.sections
.filter((section) => section.kind !== "dynamic")
.flatMap(
(section) =>
generateResponsiveGridFor({
items: elements,
previousWidth: sourceLayout.columnCount,
width: targetLayout.columnCount,
sectionId: section.id,
}).items,
const emptyRoots = board.sections
.filter((section) => section.kind === "empty")
.toSorted((first, second) => first.xOffset - second.xOffset || first.id.localeCompare(second.id));
const rootByLane = new Map<BoardLane, (typeof emptyRoots)[number]>();
for (const root of emptyRoots) {
const lane = getRootSectionLane(root.xOffset);
if (!rootByLane.has(lane)) rootByLane.set(lane, root);
}
const mainRoot = rootByLane.get("main");
if (!mainRoot) return [];
const sourceRootById = new Map(
emptyRoots.map((section) => [section.id, getRootSectionLane(section.xOffset)] as const),
);
const targetLaneBySourceLane = new Map<BoardLane, BoardLane>(
boardLanes.map((lane) => [
lane,
lane !== "main" && getBoardLaneColumnCount(targetLayout, lane) === 0 ? "main" : lane,
]),
);
const remappedElements = elements.map((element) => {
const sourceLane = sourceRootById.get(element.sectionId);
if (!sourceLane) return element;
const targetLane = targetLaneBySourceLane.get(sourceLane) ?? "main";
return { ...element, sectionId: (rootByLane.get(targetLane) ?? mainRoot).id };
});
return boardLanes.flatMap((lane) => {
const root = rootByLane.get(lane);
const width = getBoardLaneColumnCount(targetLayout, lane);
if (!root || width === 0) return [];
const sourceLanes = boardLanes.filter(
(sourceLane) =>
rootByLane.has(sourceLane) &&
getBoardLaneColumnCount(sourceLayout, sourceLane) > 0 &&
targetLaneBySourceLane.get(sourceLane) === lane,
);
const previousWidth =
sourceLanes.length === 1
? getBoardLaneColumnCount(sourceLayout, sourceLanes[0] ?? "main")
: Number.MAX_SAFE_INTEGER;
return generateResponsiveGridFor({
items: remappedElements,
previousWidth,
width,
sectionId: root.id,
}).items;
});
};
const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem[] => [
@@ -59,7 +103,7 @@ const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem
: [];
}),
...board.sections.flatMap((section) => {
if (section.kind !== "dynamic") return [];
if (section.kind !== "container") return [];
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout?.parentSectionId
? [
@@ -11,10 +11,18 @@ import type { Board } from "./_types";
interface Props {
initialBoard: Board;
initialLayoutId: string;
initialViewportWidth: number;
canModify: boolean;
}
export const BoardProviders = ({ children, initialBoard, canModify }: PropsWithChildren<Props>) => {
export const BoardProviders = ({
children,
initialBoard,
initialLayoutId,
initialViewportWidth,
canModify,
}: PropsWithChildren<Props>) => {
const searchParams = useSearchParams();
const isDesktop = useMediaQuery("(min-width: 64em)");
const requestedLayoutId = searchParams.get("layout");
@@ -23,7 +31,12 @@ export const BoardProviders = ({ children, initialBoard, canModify }: PropsWithC
const layoutOverrideId = isLayoutEditRequest && isDesktop ? requestedLayoutId : null;
return (
<BoardProvider initialBoard={initialBoard} layoutOverrideId={layoutOverrideId}>
<BoardProvider
initialBoard={initialBoard}
initialLayoutId={initialLayoutId}
initialViewportWidth={initialViewportWidth}
layoutOverrideId={layoutOverrideId}
>
<EditModeProvider initialOpen={isLayoutEditRequest}>{children}</EditModeProvider>
</BoardProvider>
);
@@ -7,7 +7,7 @@ export const getFirstEmptyPosition = (
size: { width: number; height: number } = { width: 1, height: 1 },
) => {
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
const isOccupied = elements.some(
(element) =>
element.yOffset < yOffset + size.height &&
@@ -16,6 +16,7 @@ describe("board lanes", () => {
leftGutterColumnCount: 2,
rightGutterColumnCount: 3,
breakpoint: 0,
role: "base",
} satisfies Board["layouts"][number];
expect(getBoardLaneColumnCount(layout, "left")).toBe(2);
@@ -31,6 +32,7 @@ describe("board lanes", () => {
leftGutterColumnCount: 3,
rightGutterColumnCount: 3,
breakpoint: 0,
role: "base",
} satisfies Board["layouts"][number];
expect(getBoardLaneColumnCount(layout, "left")).toBe(3);
@@ -120,6 +122,7 @@ describe("board lanes", () => {
leftGutterColumnCount: 1,
rightGutterColumnCount: 0,
breakpoint: 0,
role: "base",
},
];
@@ -1,11 +0,0 @@
import { describe, expect, test } from "vitest";
import { filterRecursiveInnerSections } from "./gridstack";
describe("filterRecursiveInnerSections", () => {
test("removes sections that would recursively render an ancestor", () => {
const innerSections = [{ id: "safe" }, { id: "parent" }, { id: "root" }];
expect(filterRecursiveInnerSections(innerSections, new Set(["root", "parent"]))).toEqual([{ id: "safe" }]);
});
});
@@ -1,18 +1,18 @@
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 { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-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", () => {
test("rejects moving a container into itself or one of its descendants", () => {
const layoutId = "layout";
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
const parentSection = new DynamicSectionMockBuilder({ id: "parent" })
const parentSection = new ContainerSectionMockBuilder({ id: "parent" })
.addLayout({ layoutId, parentSectionId: rootSection.id })
.build();
const childSection = new DynamicSectionMockBuilder({ id: "child" })
const childSection = new ContainerSectionMockBuilder({ id: "child" })
.addLayout({ layoutId, parentSectionId: parentSection.id })
.build();
const board = new BoardMockBuilder().addSections([rootSection, parentSection, childSection]).build();
@@ -101,7 +101,7 @@ export const wouldCreateSectionCycle = (
visitedSectionIds.add(currentSectionId);
const currentSection = board.sections.find((section) => section.id === currentSectionId);
if (currentSection?.kind !== "dynamic") return false;
if (currentSection?.kind !== "container") return false;
currentSectionId = currentSection.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId ?? null;
}
@@ -21,17 +21,9 @@ interface UserAvatarMenuProps {
children: ReactNode;
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
isDockerEnabled?: boolean;
opened: boolean;
onOpenChange: (opened: boolean) => void;
}
export const UserAvatarMenu = ({
children,
availableUpdates,
isDockerEnabled,
opened,
onOpenChange,
}: UserAvatarMenuProps) => {
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const session = useSession();
@@ -48,7 +40,7 @@ export const UserAvatarMenu = ({
return (
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}>
<Menu width={300} withinPortal keepMounted>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>