chore: reconcile mobile layer after stack rebase
This commit is contained in:
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" />}>
|
||||
|
||||
Reference in new issue
Block a user