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>
);