feat: protect responsive board layouts

This commit is contained in:
ajnart committed 2026-08-13 19:55:38 +02:00
1 parent 4df62221c7
commit 30c00ea30d
30 files changed
+1055 -254

No files matched your search

@@ -0,0 +1,39 @@
.canvas {
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: color-mix(in srgb, var(--mantine-color-body) 82%, var(--mantine-primary-color-light));
padding: var(--mantine-spacing-xs);
min-height: 7rem;
}
.section {
border-radius: var(--mantine-radius-sm);
background: color-mix(in srgb, var(--mantine-color-default) 78%, transparent);
padding: 0.35rem;
}
.grid {
display: grid;
gap: 3px;
grid-auto-rows: clamp(0.65rem, 1.7vw, 1.15rem);
min-height: 2.5rem;
}
.tile {
min-width: 0;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--mantine-primary-color-filled) 24%, var(--mantine-color-default-border));
border-radius: calc(var(--mantine-radius-sm) * 0.75);
background: color-mix(in srgb, var(--mantine-primary-color-light) 70%, var(--mantine-color-body));
padding-inline: 0.3rem;
}
.dynamicTile {
border-style: dashed;
background: color-mix(in srgb, var(--mantine-color-default) 88%, var(--mantine-primary-color-light));
}
.appIcon {
width: 0.85rem;
height: 0.85rem;
}
@@ -0,0 +1,126 @@
"use client";
import { Badge, Box, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { IconLayoutGrid } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import type { Board } from "../../_types";
import type { BoardLayout } from "../../_layout-utils";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "../../_layout-utils";
import classes from "./_layout-preview.module.css";
interface Props {
board: Board;
layout: BoardLayout;
layouts: BoardLayout[];
sourceLayout: Pick<BoardLayout, "id" | "columnCount">;
apps: RouterOutputs["app"]["byIds"];
}
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => {
const t = useI18n();
const elements = projectBoardLayout(board, sourceLayout, layout);
const representativeWidth = getRepresentativeLayoutWidth(layout, layouts);
const largestRepresentativeWidth = Math.max(
...layouts.map((candidate) => getRepresentativeLayoutWidth(candidate, layouts)),
);
const previewWidth = `${(representativeWidth / largestRepresentativeWidth) * 100}%`;
const appsById = new Map(apps.map((app) => [app.id, app]));
const rootSections = board.sections
.filter((section) => section.kind !== "dynamic")
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
return (
<Stack gap={6} align="center" w="100%">
<Group gap="xs" justify="center">
<Badge variant="light" color={layout.role === "mobile" ? "teal" : layout.role === "base" ? "blue" : "gray"}>
{representativeWidth}px
</Badge>
<Text size="xs">
{layout.columnCount} {t("board.setting.section.layout.preview.columns")}
</Text>
</Group>
<Box w={previewWidth} miw="min(11rem, 100%)" maw="100%" className={classes.canvas}>
{elements.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="xl">
{t("board.setting.section.layout.preview.empty")}
</Text>
) : (
<Stack gap="xs">
{rootSections.map((section) => {
const sectionElements = elements.filter((element) => element.sectionId === section.id);
if (sectionElements.length === 0) return null;
return (
<Stack key={section.id} gap={4} className={classes.section}>
{section.kind === "category" && (
<Text size="xs" fw={600} truncate>
{section.name}
</Text>
)}
<Box
className={classes.grid}
style={{ gridTemplateColumns: `repeat(${layout.columnCount}, minmax(0, 1fr))` }}
>
{sectionElements.map((element) => {
const item =
element.type === "item" ? board.items.find((candidate) => candidate.id === element.id) : null;
const appId =
item?.kind === "app" && typeof item.options.appId === "string" ? item.options.appId : null;
const app = appId ? appsById.get(appId) : undefined;
const dynamicSection =
element.type === "section"
? board.sections.find((candidate) => candidate.id === element.id)
: undefined;
const dynamicSectionLabel =
dynamicSection?.kind === "dynamic" && dynamicSection.options.title
? dynamicSection.options.title
: t("board.setting.section.layout.preview.dynamicSection");
const label = app?.name ?? (item ? t(`widget.${item.kind}.name`) : dynamicSectionLabel);
const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid;
return (
<Tooltip key={`${element.type}-${element.id}`} label={label} openDelay={350}>
<Group
gap={4}
wrap="nowrap"
align="center"
className={`${classes.tile} ${element.type === "section" ? classes.dynamicTile : ""}`}
style={{
gridColumn: `${element.xOffset + 1} / span ${element.width}`,
gridRow: `${element.yOffset + 1} / span ${element.height}`,
}}
>
<ThemeIcon size="sm" variant="light" radius="sm" style={{ flexShrink: 0 }}>
{app ? (
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={false}
alt=""
className={classes.appIcon}
/>
) : (
<WidgetIcon size={13} stroke={1.7} />
)}
</ThemeIcon>
<Text size="xs" fw={500} truncate>
{label}
</Text>
</Group>
</Tooltip>
);
})}
</Box>
</Stack>
);
})}
</Stack>
)}
</Box>
</Stack>
);
};
@@ -0,0 +1,75 @@
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 { 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 };
describe("layout representative widths", () => {
test("uses a phone width, activation midpoint, and desktop minimum", () => {
const layouts = [mobile, custom, base];
expect(getRepresentativeLayoutWidth(mobile, layouts)).toBe(390);
expect(getRepresentativeLayoutWidth(custom, layouts)).toBe(624);
expect(getRepresentativeLayoutWidth(base, layouts)).toBe(1280);
});
});
describe("projectBoardLayout", () => {
test("uses production ordering and sizing for items and dynamic sections", () => {
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
.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 })
.build();
const board = new BoardMockBuilder()
.addLayout(mobile)
.addSections([rootSection, dynamicSection])
.addItems([rootItem, nestedItem])
.build();
const projected = projectBoardLayout(board, base, mobile);
expect(projected.find((element) => element.id === rootItem.id)).toMatchObject({
type: "item",
width: 3,
height: 2,
xOffset: 0,
yOffset: 0,
sectionId: rootSection.id,
});
expect(projected.find((element) => element.id === dynamicSection.id)).toMatchObject({
type: "section",
width: 3,
xOffset: 0,
yOffset: 2,
sectionId: rootSection.id,
});
expect(projected.find((element) => element.id === nestedItem.id)).toMatchObject({
type: "item",
width: 3,
sectionId: dynamicSection.id,
});
});
test("ignores missing saved positions and supports empty boards", () => {
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
const itemWithoutBasePosition = new ItemMockBuilder({ id: "missing", kind: "clock" }).build();
const board = new BoardMockBuilder()
.addLayout(mobile)
.addSection(rootSection)
.addItem(itemWithoutBasePosition)
.build();
expect(projectBoardLayout(board, base, mobile)).toEqual([]);
});
});
@@ -0,0 +1,74 @@
import { generateResponsiveGridFor } from "@homarr/common";
import type { GridAlgorithmItem } from "@homarr/common";
import type { Board } from "./_types";
export type BoardLayout = Board["layouts"][number];
export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: BoardLayout[]) => {
if (layout.role === "mobile") return 390;
if (layout.role === "base") return Math.max(1280, layout.breakpoint);
const nextLayout = allLayouts
.filter((candidate) => candidate.breakpoint > layout.breakpoint)
.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint)
.at(0);
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">,
) => {
const elements = getElementsForLayout(board, sourceLayout.id);
return board.sections
.filter((section) => section.kind !== "dynamic")
.flatMap(
(section) =>
generateResponsiveGridFor({
items: elements,
previousWidth: sourceLayout.columnCount,
width: targetLayout.columnCount,
sectionId: section.id,
}).items,
);
};
const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem[] => [
...board.items.flatMap((item) => {
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout
? [
{
id: item.id,
type: "item" as const,
height: layout.height,
width: layout.width,
xOffset: layout.xOffset,
yOffset: layout.yOffset,
sectionId: layout.sectionId,
},
]
: [];
}),
...board.sections.flatMap((section) => {
if (section.kind !== "dynamic") return [];
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout?.parentSectionId
? [
{
id: section.id,
type: "section" as const,
height: layout.height,
width: layout.width,
xOffset: layout.xOffset,
yOffset: layout.yOffset,
sectionId: layout.parentSectionId,
},
]
: [];
}),
];
@@ -0,0 +1,30 @@
"use client";
import type { PropsWithChildren } from "react";
import { useSearchParams } from "next/navigation";
import { useMediaQuery } from "@mantine/hooks";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
import type { Board } from "./_types";
interface Props {
initialBoard: Board;
canModify: boolean;
}
export const BoardProviders = ({ children, initialBoard, canModify }: PropsWithChildren<Props>) => {
const searchParams = useSearchParams();
const isDesktop = useMediaQuery("(min-width: 64em)");
const requestedLayoutId = searchParams.get("layout");
const isRequestedLayoutValid = initialBoard.layouts.some((layout) => layout.id === requestedLayoutId);
const isLayoutEditRequest = canModify && searchParams.get("edit") === "true" && isRequestedLayoutValid;
const layoutOverrideId = isLayoutEditRequest && isDesktop ? requestedLayoutId : null;
return (
<BoardProvider initialBoard={initialBoard} layoutOverrideId={layoutOverrideId}>
<EditModeProvider initialOpen={isLayoutEditRequest}>{children}</EditModeProvider>
</BoardProvider>
);
};
@@ -47,7 +47,8 @@ export class BoardMockBuilder {
columnCount: 12,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
breakpoint: 768,
role: "base",
},
],
...board,
@@ -12,7 +12,8 @@ export class LayoutMockBuilder {
columnCount: 12,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
breakpoint: 768,
role: "base",
...layout,
} satisfies Board["layouts"][0];
}
@@ -1,186 +0,0 @@
export interface GridAlgorithmItem {
id: string;
type: "item" | "section";
width: number;
height: number;
xOffset: number;
yOffset: number;
sectionId: string;
}
interface GridAlgorithmInput {
items: GridAlgorithmItem[];
width: number;
previousWidth: number;
sectionId: string;
}
interface GridAlgorithmOutput {
height: number;
items: GridAlgorithmItem[];
}
export const generateResponsiveGridFor = ({
items,
previousWidth,
width,
sectionId,
}: GridAlgorithmInput): GridAlgorithmOutput => {
const itemsOfCurrentSection = items
.filter((item) => item.sectionId === sectionId)
.toSorted((itemA, itemB) =>
itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
);
const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
if (itemsOfCurrentSection.length === 0) {
return {
height: 0,
items: [],
};
}
const newItems: GridAlgorithmItem[] = [];
// A container must remain tall enough for its recursively reflowed contents.
const containerHeightMap = new Map<string, number>();
const containersOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
for (const container of containersOfCurrentSection) {
const result = generateResponsiveGridFor({
items,
previousWidth: container.previousWidth,
width: container.width,
sectionId: container.id,
});
newItems.push(...result.items);
containerHeightMap.set(container.id, result.height);
}
// Return same positions for items in the current section
if (width >= previousWidth) {
return {
height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
items: newItems.concat(normalizedItems),
};
}
const occupied2d: boolean[][] = [];
for (const item of normalizedItems) {
const itemWithHeight = {
...item,
height: item.type === "section" ? Math.max(containerHeightMap.get(item.id) ?? 1, item.height) : item.height,
};
const position = nextFreeSpot(occupied2d, itemWithHeight, width);
if (!position) throw new Error("No free spot available");
addItemToOccupied(occupied2d, itemWithHeight, position, width);
newItems.push({
...itemWithHeight,
xOffset: position.x,
yOffset: position.y,
});
}
return {
height: occupied2d.length,
items: newItems,
};
};
/**
* Reduces the width of the items to fit the new column count.
* @param items items to normalize
* @param columnCount new column count
*/
const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) => {
return items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
};
/**
* Adds the item to the occupied spots.
* @param occupied2d array of occupied spots
* @param item item to place
* @param position position to place the item
*/
const addItemToOccupied = (
occupied2d: boolean[][],
item: GridAlgorithmItem,
position: { x: number; y: number },
columnCount: number,
) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
let row = occupied2d[position.y + yOffset];
if (!row) {
addRow(occupied2d, columnCount);
// After adding it, it must exist
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
row = occupied2d[position.y + yOffset]!;
}
for (let xOffset = 0; xOffset < item.width; xOffset++) {
row[position.x + xOffset] = true;
}
}
};
/**
* Adds a new row to the grid.
* @param occupied2d array of occupied spots
* @param columnCount column count of section
*/
const addRow = (occupied2d: boolean[][], columnCount: number) => {
occupied2d.push(new Array<boolean>(columnCount).fill(false));
};
/**
* Searches for the next free spot in the grid.
* @param occupied2d array of occupied spots
* @param item item to place
* @param columnCount column count of section
* @returns the position of the next free spot or null if no spot is available
*/
const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
for (let offsetY = 0; offsetY < 99999; offsetY++) {
for (let offsetX = 0; offsetX < columnCount; offsetX++) {
if (hasHorizontalSpace(columnCount, item, offsetX) && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
return { x: offsetX, y: offsetY };
}
}
}
return null;
};
/**
* Check if the item fits into the grid horizontally.
* @param columnCount available width
* @param item item to place
* @param offsetX current x position
* @returns true if the item fits horizontally
*/
const hasHorizontalSpace = (columnCount: number, item: GridAlgorithmItem, offsetX: number) => {
return offsetX + item.width <= columnCount;
};
/**
* Check if the spot is free.
* @param occupied2d array of occupied spots
* @param item item to place
* @param position position to check
* @returns true if the spot is free
*/
const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
const row = occupied2d[position.y + yOffset];
if (!row) return true; // Empty row is free
for (let xOffset = 0; xOffset < item.width; xOffset++) {
if (row[position.x + xOffset]) {
return false;
}
}
}
return true;
};
@@ -1,8 +1,8 @@
import { createId } from "@paralleldrive/cuid2";
import { describe, expect, test } from "vitest";
import type { GridAlgorithmItem } from "../grid-algorithm";
import { generateResponsiveGridFor } from "../grid-algorithm";
import { generateResponsiveGridFor } from "@homarr/common";
import type { GridAlgorithmItem } from "@homarr/common";
const ROOT_SECTION_ID = "section";
@@ -294,9 +294,7 @@ export const onboardRouter = createTRPCRouter({
}
const section = board.sections.find((boardSection) => boardSection.kind === "empty");
const layout = board.layouts[0];
if (section && layout) {
if (section && board.layouts.length > 0) {
const existingItems = await db.query.items.findMany({
where: eq(items.boardId, board.id),
});
@@ -310,12 +308,12 @@ export const onboardRouter = createTRPCRouter({
await placeAllWidgetsAsync(
db,
{
board.layouts.map((layout) => ({
boardId: board.id,
sectionId: section.id,
layoutId: layout.id,
columnCount: layout.columnCount,
},
})),
allIntegrations,
allApps,
);
+8 -3
View File
@@ -1,13 +1,18 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import { createContext, useContext, useEffect } from "react";
import { useDisclosure } from "@mantine/hooks";
const EditModeContext = createContext<ReturnType<typeof useDisclosure> | null>(null);
export const EditModeProvider = ({ children }: PropsWithChildren) => {
const editModeDisclosure = useDisclosure(false);
export const EditModeProvider = ({ children, initialOpen = false }: PropsWithChildren<{ initialOpen?: boolean }>) => {
const editModeDisclosure = useDisclosure(initialOpen);
const [, { open }] = editModeDisclosure;
useEffect(() => {
if (initialOpen) open();
}, [initialOpen, open]);
return <EditModeContext.Provider value={editModeDisclosure}>{children}</EditModeContext.Provider>;
};
+125
View File
@@ -0,0 +1,125 @@
export interface GridAlgorithmItem {
id: string;
type: "item" | "section";
width: number;
height: number;
xOffset: number;
yOffset: number;
sectionId: string;
}
interface GridAlgorithmInput {
items: GridAlgorithmItem[];
width: number;
previousWidth: number;
sectionId: string;
}
interface GridAlgorithmOutput {
height: number;
items: GridAlgorithmItem[];
}
export const generateResponsiveGridFor = ({
items,
previousWidth,
width,
sectionId,
}: GridAlgorithmInput): GridAlgorithmOutput =>
generateResponsiveGridForSection({ items, previousWidth, width, sectionId }, new Set());
const generateResponsiveGridForSection = (
{ items, previousWidth, width, sectionId }: GridAlgorithmInput,
ancestorSectionIds: ReadonlySet<string>,
): GridAlgorithmOutput => {
if (ancestorSectionIds.has(sectionId)) return { height: 0, items: [] };
const nextAncestorSectionIds = new Set(ancestorSectionIds).add(sectionId);
const itemsOfCurrentSection = items
.filter((item) => item.sectionId === sectionId)
.toSorted((itemA, itemB) =>
itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
);
const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
if (itemsOfCurrentSection.length === 0) return { height: 0, items: [] };
const newItems: GridAlgorithmItem[] = [];
const dynamicSectionHeightMap = new Map<string, number>();
for (const dynamicSection of normalizedItems.filter((item) => item.type === "section")) {
const result = generateResponsiveGridForSection(
{
items,
previousWidth: dynamicSection.previousWidth,
width: dynamicSection.width,
sectionId: dynamicSection.id,
},
nextAncestorSectionIds,
);
newItems.push(...result.items);
dynamicSectionHeightMap.set(dynamicSection.id, result.height);
}
if (width >= previousWidth) {
return {
height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
items: newItems.concat(normalizedItems),
};
}
const occupied2d: boolean[][] = [];
for (const item of normalizedItems) {
const itemWithHeight = {
...item,
height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height,
};
const position = nextFreeSpot(occupied2d, itemWithHeight, width);
if (!position) throw new Error("No free spot available");
addItemToOccupied(occupied2d, itemWithHeight, position, width);
newItems.push({ ...itemWithHeight, xOffset: position.x, yOffset: position.y });
}
return { height: occupied2d.length, items: newItems };
};
const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) =>
items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
const addItemToOccupied = (
occupied2d: boolean[][],
item: GridAlgorithmItem,
position: { x: number; y: number },
columnCount: number,
) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
let row = occupied2d[position.y + yOffset];
if (!row) {
row = Array.from({ length: columnCount }, () => false);
occupied2d.push(row);
}
for (let xOffset = 0; xOffset < item.width; xOffset++) row[position.x + xOffset] = true;
}
};
const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
for (let offsetY = 0; offsetY < 99999; offsetY++) {
for (let offsetX = 0; offsetX < columnCount; offsetX++) {
if (offsetX + item.width <= columnCount && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
return { x: offsetX, y: offsetY };
}
}
}
return null;
};
const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
const row = occupied2d[position.y + yOffset];
if (!row) return true;
for (let xOffset = 0; xOffset < item.width; xOffset++) {
if (row[position.x + xOffset]) return false;
}
}
return true;
};
+1
View File
@@ -11,4 +11,5 @@ export * from "./number";
export * from "./error";
export * from "./theme";
export * from "./function";
export * from "./grid-algorithm";
export * from "./id";
@@ -0,0 +1 @@
ALTER TABLE `layout` ADD `role` varchar(16) DEFAULT 'custom' NOT NULL;
@@ -323,6 +323,13 @@
"when": 1786212549933,
"tag": "0045_add_layout_gutters",
"breakpoints": true
},
{
"idx": 46,
"version": "5",
"when": 1786215199760,
"tag": "0046_add_layout_role",
"breakpoints": true
}
]
}
@@ -0,0 +1 @@
ALTER TABLE "layout" ADD COLUMN "role" varchar(16) DEFAULT 'custom' NOT NULL;
@@ -85,6 +85,13 @@
"when": 1786212457549,
"tag": "0011_add_layout_gutters",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1786215201393,
"tag": "0012_add_layout_role",
"breakpoints": true
}
]
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, test } from "vitest";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { boards, itemLayouts, items, layouts, sections } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { emptySuperJSON } from "@homarr/definitions";
import { seedProtectedBoardLayoutsAsync } from "./seed";
describe("protected board layout migration", () => {
test("turns a single saved layout into Base and generates Mobile", async () => {
const db = createDb();
const boardId = createId();
const baseLayoutId = createId();
const sectionId = createId();
const itemId = createId();
await db.insert(boards).values({ id: boardId, name: "single" });
await db.insert(layouts).values({
id: baseLayoutId,
boardId,
name: "Wide",
columnCount: 12,
breakpoint: 0,
});
await db.insert(sections).values({ id: sectionId, boardId, kind: "empty", xOffset: 0, yOffset: 0 });
await db.insert(items).values({ id: itemId, boardId, kind: "clock", options: emptySuperJSON });
await db.insert(itemLayouts).values({
itemId,
sectionId,
layoutId: baseLayoutId,
width: 5,
height: 2,
xOffset: 6,
yOffset: 1,
});
await seedProtectedBoardLayoutsAsync(db);
await seedProtectedBoardLayoutsAsync(db);
const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
expect(savedLayouts).toHaveLength(2);
expect(savedLayouts.find((layout) => layout.role === "base")).toMatchObject({
id: baseLayoutId,
name: "Wide",
columnCount: 12,
breakpoint: 768,
});
const mobileLayout = savedLayouts.find((layout) => layout.role === "mobile");
expect(mobileLayout).toMatchObject({ name: "Mobile", columnCount: 3, breakpoint: 0 });
const mobileItemPosition = await db.query.itemLayouts.findFirst({
where: eq(itemLayouts.layoutId, mobileLayout?.id ?? ""),
});
expect(mobileItemPosition).toMatchObject({ width: 3, height: 2, xOffset: 0, yOffset: 0 });
});
test("assigns roles and resolves duplicate breakpoints without rebuilding existing layouts", async () => {
const db = createDb();
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "responsive" });
await db.insert(layouts).values([
{ id: "layout-mobile", boardId, name: "Phone", columnCount: 3, breakpoint: 0 },
{ id: "layout-tablet-a", boardId, name: "Tablet A", columnCount: 6, breakpoint: 480 },
{ id: "layout-tablet-b", boardId, name: "Tablet B", columnCount: 8, breakpoint: 480 },
{ id: "layout-wide", boardId, name: "Wide", columnCount: 12, breakpoint: 1200 },
]);
await seedProtectedBoardLayoutsAsync(db);
await seedProtectedBoardLayoutsAsync(db);
const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
expect(savedLayouts.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint)).toMatchObject([
{ id: "layout-mobile", name: "Phone", columnCount: 3, breakpoint: 0, role: "mobile" },
{ id: "layout-tablet-a", name: "Tablet A", columnCount: 6, breakpoint: 480, role: "custom" },
{ id: "layout-tablet-b", name: "Tablet B", columnCount: 8, breakpoint: 481, role: "custom" },
{ id: "layout-wide", name: "Wide", columnCount: 12, breakpoint: 1200, role: "base" },
]);
});
test("creates both protected layouts for a board with no layouts", async () => {
const db = createDb();
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "empty" });
await seedProtectedBoardLayoutsAsync(db);
await seedProtectedBoardLayoutsAsync(db);
const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
expect(savedLayouts).toHaveLength(2);
expect(savedLayouts).toEqual(
expect.arrayContaining([
expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }),
expect.objectContaining({ role: "base", columnCount: 10, breakpoint: 768 }),
]),
);
});
});
+234 -11
View File
@@ -1,6 +1,6 @@
import SuperJSON from "superjson";
import { createId, objectKeys } from "@homarr/common";
import { createId, generateResponsiveGridFor, objectKeys } from "@homarr/common";
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import {
createDocumentationLink,
@@ -11,11 +11,12 @@ import {
getIntegrationName,
integrationDefs,
integrationKinds,
normalizeBoardLayoutRoles,
} from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
import type { Database, InferInsertModel } from "..";
import { eq, inArray } from "..";
import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
import {
@@ -39,6 +40,7 @@ import {
onboarding,
searchEngines,
sections,
sectionLayouts,
users,
} from "../schema";
import type { Integration } from "../schema";
@@ -65,6 +67,215 @@ export const seedDataAsync = async (db: Database) => {
if (isTruthyEnv(process.env.DEMO_MODE)) {
await seedDemoUserAsync(db);
}
await seedProtectedBoardLayoutsAsync(db);
};
export const seedProtectedBoardLayoutsAsync = async (db: Database, boardId?: string) => {
const dbBoards = await db.query.boards.findMany({
where: boardId ? eq(boards.id, boardId) : undefined,
with: {
layouts: true,
items: { with: { layouts: true } },
sections: { with: { layouts: true } },
},
});
for (const board of dbBoards) {
if (board.layouts.length === 0) {
await db.insert(layouts).values([
{
id: createId(),
name: "Mobile",
columnCount: 3,
breakpoint: 0,
role: "mobile",
boardId: board.id,
},
{
id: createId(),
name: "Base",
columnCount: 10,
breakpoint: 768,
role: "base",
boardId: board.id,
},
]);
continue;
}
if (board.layouts.length === 1) {
const [baseLayout] = board.layouts;
if (!baseLayout) continue;
const mobileLayout = {
id: createId(),
name: "Mobile",
columnCount: 3,
breakpoint: 0,
role: "mobile" as const,
boardId: board.id,
};
await db.update(layouts).set({ role: "base", breakpoint: 768 }).where(eq(layouts.id, baseLayout.id));
await db.insert(layouts).values(mobileLayout);
await insertMissingProjectedPositionsAsync(
db,
board,
{ ...baseLayout, role: "base", breakpoint: 768 },
mobileLayout,
);
continue;
}
const normalizedLayouts = normalizeBoardLayoutRoles(board.layouts);
const alreadyNormalized = normalizedLayouts.every((layout) => {
const previousLayout = board.layouts.find((candidate) => candidate.id === layout.id);
return layout.breakpoint === previousLayout?.breakpoint && previousLayout.role === layout.role;
});
if (!alreadyNormalized) {
for (const layout of normalizedLayouts) {
await db
.update(layouts)
.set({ breakpoint: layout.breakpoint, role: layout.role })
.where(eq(layouts.id, layout.id));
}
}
const mobileLayout = normalizedLayouts[0];
const baseLayout = normalizedLayouts.at(-1);
if (mobileLayout && baseLayout) {
await insertMissingProjectedPositionsAsync(
db,
board,
{ ...baseLayout, role: "base" },
{ ...mobileLayout, role: "mobile" },
);
}
}
};
interface BoardWithLayoutPositions {
items: Array<{
id: string;
layouts: Array<{
layoutId: string;
sectionId: string;
width: number;
height: number;
xOffset: number;
yOffset: number;
}>;
}>;
sections: Array<{
id: string;
kind: string;
layouts: Array<{
layoutId: string;
parentSectionId: string | null;
width: number;
height: number;
xOffset: number;
yOffset: number;
}>;
}>;
}
const insertMissingProjectedPositionsAsync = async (
db: Database,
board: BoardWithLayoutPositions,
sourceLayout: InferInsertModel<typeof layouts>,
targetLayout: InferInsertModel<typeof layouts>,
) => {
const elements = [
...board.items.flatMap((item) => {
const layout = item.layouts.find((itemLayout) => itemLayout.layoutId === sourceLayout.id);
return layout
? [
{
id: item.id,
type: "item" as const,
width: layout.width,
height: layout.height,
xOffset: layout.xOffset,
yOffset: layout.yOffset,
sectionId: layout.sectionId,
},
]
: [];
}),
...board.sections.flatMap((section) => {
if (section.kind !== "dynamic") return [];
const layout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === sourceLayout.id);
return layout?.parentSectionId
? [
{
id: section.id,
type: "section" as const,
width: layout.width,
height: layout.height,
xOffset: layout.xOffset,
yOffset: layout.yOffset,
sectionId: layout.parentSectionId,
},
]
: [];
}),
];
const projectedElements = board.sections
.filter((section) => section.kind !== "dynamic")
.flatMap(
(section) =>
generateResponsiveGridFor({
items: elements,
previousWidth: sourceLayout.columnCount,
width: targetLayout.columnCount,
sectionId: section.id,
}).items,
);
const existingItemIds = new Set(
board.items
.filter((item) => item.layouts.some((layout) => layout.layoutId === targetLayout.id))
.map((item) => item.id),
);
const itemPositions = projectedElements
.filter((element) => element.type === "item" && !existingItemIds.has(element.id))
.map(
(element): InferInsertModel<typeof itemLayouts> => ({
itemId: element.id,
layoutId: targetLayout.id,
sectionId: element.sectionId,
width: element.width,
height: element.height,
xOffset: element.xOffset,
yOffset: element.yOffset,
}),
);
const existingSectionIds = new Set(
board.sections
.filter((section) => section.layouts.some((layout) => layout.layoutId === targetLayout.id))
.map((section) => section.id),
);
const sectionPositions = projectedElements
.filter((element) => element.type === "section" && !existingSectionIds.has(element.id))
.map(
(element): InferInsertModel<typeof sectionLayouts> => ({
sectionId: element.id,
layoutId: targetLayout.id,
parentSectionId: element.sectionId,
width: element.width,
height: element.height,
xOffset: element.xOffset,
yOffset: element.yOffset,
}),
);
if (itemPositions.length > 0) await db.insert(itemLayouts).values(itemPositions);
if (sectionPositions.length > 0) await db.insert(sectionLayouts).values(sectionPositions);
};
const seedEveryoneGroupAsync = async (db: Database) => {
@@ -271,13 +482,24 @@ const seedDefaultBoardAsync = async (db: Database) => {
yOffset: 0,
boardId,
});
await db.insert(layouts).values({
id: createId(),
name: "Base",
columnCount: 10,
breakpoint: 0,
boardId,
});
await db.insert(layouts).values([
{
id: createId(),
name: "Mobile",
columnCount: 3,
breakpoint: 0,
role: "mobile",
boardId,
},
{
id: createId(),
name: "Base",
columnCount: 10,
breakpoint: 768,
role: "base",
boardId,
},
]);
const everyoneGroupRow = await db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
@@ -508,7 +730,8 @@ const seedDemoUserAsync = async (db: Database) => {
id: layoutId,
name: "Base",
columnCount: 12,
breakpoint: 0,
breakpoint: 768,
role: "base",
boardId,
});
@@ -598,7 +821,7 @@ const seedBoardWidgetsAsync = async (db: Database) => {
if (!board) return;
const section = board.sections.find((sec) => sec.kind === "empty");
const layout = board.layouts[0];
const layout = board.layouts.find((candidate) => candidate.role === "base") ?? board.layouts[0];
if (!section || !layout) return;
const allIntegrations = await db.query.integrations.findMany();
@@ -0,0 +1 @@
ALTER TABLE `layout` ADD `role` text DEFAULT 'custom' NOT NULL;
@@ -309,6 +309,13 @@
"when": 1786212459480,
"tag": "0043_add_layout_gutters",
"breakpoints": true
},
{
"idx": 44,
"version": "6",
"when": 1786215203092,
"tag": "0044_add_layout_role",
"breakpoints": true
}
]
}
@@ -0,0 +1,57 @@
import { describe, expect, test } from "vitest";
import { createId } from "@homarr/common";
import { boards, layouts, sections } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { placeAllWidgetsAsync } from "./widget-placement";
describe("placeAllWidgetsAsync", () => {
test("creates independent positions for every target layout", async () => {
const db = createDb();
const boardId = createId();
const sectionId = createId();
const mobileLayoutId = createId();
const baseLayoutId = createId();
await db.insert(boards).values({ id: boardId, name: "dashboard" });
await db.insert(sections).values({ id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId });
await db.insert(layouts).values([
{
id: mobileLayoutId,
name: "Mobile",
role: "mobile",
columnCount: 3,
breakpoint: 0,
boardId,
},
{
id: baseLayoutId,
name: "Base",
role: "base",
columnCount: 10,
breakpoint: 768,
boardId,
},
]);
await placeAllWidgetsAsync(
db,
[
{ boardId, sectionId, layoutId: mobileLayoutId, columnCount: 3 },
{ boardId, sectionId, layoutId: baseLayoutId, columnCount: 10 },
],
[],
[{ id: createId(), name: "Homarr" }],
);
const placedItems = await db.query.items.findMany({ with: { layouts: true } });
expect(placedItems.length).toBeGreaterThan(0);
expect(placedItems.every((item) => item.layouts.length === 2)).toBe(true);
expect(
placedItems.every((item) =>
item.layouts.some((layout) => layout.layoutId === mobileLayoutId && layout.width <= 3),
),
).toBe(true);
expect(placedItems.every((item) => item.layouts.some((layout) => layout.layoutId === baseLayoutId))).toBe(true);
});
});
+37 -28
View File
@@ -30,46 +30,54 @@ interface PlacementContext extends BoardTarget {
const placeWidgetAsync = async (
db: Database,
ctx: PlacementContext,
contexts: PlacementContext[],
kind: WidgetKind,
linkedIntegrationIds: string[],
options?: string,
size?: { width: number; height: number },
) => {
const itemWidth = size?.width ?? 2;
const itemHeight = size?.height ?? 2;
const boardId = contexts.at(0)?.boardId;
if (!boardId) return;
if (ctx.xOffset + itemWidth > ctx.columnCount) {
ctx.xOffset = 0;
ctx.yOffset += ctx.rowMaxHeight;
ctx.rowMaxHeight = 0;
}
const desiredItemWidth = size?.width ?? 2;
const itemHeight = size?.height ?? 2;
const itemId = createId();
await db.insert(items).values({
id: itemId,
boardId: ctx.boardId,
boardId,
kind,
options: options ?? emptySuperJSON,
advancedOptions: emptySuperJSON,
});
await db.insert(itemLayouts).values({
itemId,
sectionId: ctx.sectionId,
layoutId: ctx.layoutId,
xOffset: ctx.xOffset,
yOffset: ctx.yOffset,
width: itemWidth,
height: itemHeight,
});
await db.insert(itemLayouts).values(
contexts.map((ctx) => {
const itemWidth = Math.min(ctx.columnCount, desiredItemWidth);
if (ctx.xOffset + itemWidth > ctx.columnCount) {
ctx.xOffset = 0;
ctx.yOffset += ctx.rowMaxHeight;
ctx.rowMaxHeight = 0;
}
const placement = {
itemId,
sectionId: ctx.sectionId,
layoutId: ctx.layoutId,
xOffset: ctx.xOffset,
yOffset: ctx.yOffset,
width: itemWidth,
height: itemHeight,
};
ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight);
ctx.xOffset += itemWidth;
return placement;
}),
);
for (const integrationId of linkedIntegrationIds) {
await db.insert(integrationItems).values({ itemId, integrationId });
}
ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight);
ctx.xOffset += itemWidth;
};
interface App {
@@ -79,16 +87,17 @@ interface App {
export const placeAllWidgetsAsync = async (
db: Database,
target: BoardTarget,
target: BoardTarget | BoardTarget[],
allIntegrations: Integration[],
allApps: App[],
) => {
const ctx: PlacementContext = {
...target,
const targets = Array.isArray(target) ? target : [target];
const contexts: PlacementContext[] = targets.map((currentTarget) => ({
...currentTarget,
xOffset: 0,
yOffset: 0,
rowMaxHeight: 0,
};
}));
const placedWidgets = new Set<WidgetKind>();
@@ -106,7 +115,7 @@ export const placeAllWidgetsAsync = async (
const options = config?.options ? superjson.stringify(config.options) : undefined;
await placeWidgetAsync(
db,
ctx,
contexts,
widgetKind,
matchingIds,
options,
@@ -118,7 +127,7 @@ export const placeAllWidgetsAsync = async (
for (const app of allApps) {
await placeWidgetAsync(
db,
ctx,
contexts,
"app",
[],
superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }),
@@ -138,7 +147,7 @@ export const placeAllWidgetsAsync = async (
options = { ...options, items: bookmarkAppIds };
}
await placeWidgetAsync(db, ctx, config.kind, [], options ? superjson.stringify(options) : undefined, {
await placeWidgetAsync(db, contexts, config.kind, [], options ? superjson.stringify(options) : undefined, {
width: config.width,
height: config.height,
});
+2
View File
@@ -34,6 +34,7 @@ import type {
IntegrationKind,
IntegrationPermission,
IntegrationSecretKind,
LayoutRole,
OnboardingStep,
SearchEngineType,
SectionKind,
@@ -343,6 +344,7 @@ export const layouts = mysqlTable("layout", {
leftGutterColumnCount: tinyint().notNull().default(0),
rightGutterColumnCount: tinyint().notNull().default(0),
breakpoint: smallint().notNull().default(0),
role: varchar({ length: 16 }).$type<LayoutRole>().notNull().default("custom"),
});
export const itemLayouts = mysqlTable(
+2
View File
@@ -33,6 +33,7 @@ import type {
IntegrationKind,
IntegrationPermission,
IntegrationSecretKind,
LayoutRole,
OnboardingStep,
SearchEngineType,
SectionKind,
@@ -342,6 +343,7 @@ export const layouts = pgTable("layout", {
leftGutterColumnCount: smallint().notNull().default(0),
rightGutterColumnCount: smallint().notNull().default(0),
breakpoint: smallint().notNull().default(0),
role: varchar({ length: 16 }).$type<LayoutRole>().notNull().default("custom"),
});
export const itemLayouts = pgTable(
+2
View File
@@ -21,6 +21,7 @@ import type {
IntegrationKind,
IntegrationPermission,
IntegrationSecretKind,
LayoutRole,
OnboardingStep,
SearchEngineType,
SectionKind,
@@ -327,6 +328,7 @@ export const layouts = sqliteTable("layout", {
leftGutterColumnCount: int().notNull().default(0),
rightGutterColumnCount: int().notNull().default(0),
breakpoint: int().notNull().default(0),
role: text().$type<LayoutRole>().notNull().default("custom"),
});
export const itemLayouts = sqliteTable(
+42
View File
@@ -8,7 +8,49 @@ export const backgroundImageRepeats = createDefinition(["repeat", "repeat-x", "r
export const backgroundImageSizes = createDefinition(["cover", "contain"], {
defaultValue: "cover",
});
export const layoutRoles = createDefinition(["mobile", "base", "custom"], { defaultValue: "custom" });
export type BackgroundImageAttachment = inferDefinitionType<typeof backgroundImageAttachments>;
export type BackgroundImageRepeat = inferDefinitionType<typeof backgroundImageRepeats>;
export type BackgroundImageSize = inferDefinitionType<typeof backgroundImageSizes>;
export type LayoutRole = inferDefinitionType<typeof layoutRoles>;
const MAX_LAYOUT_BREAKPOINT = 32767;
const compareBoardLayouts = (
layoutA: { id: string; breakpoint: number; columnCount: number },
layoutB: { id: string; breakpoint: number; columnCount: number },
) =>
layoutA.breakpoint - layoutB.breakpoint ||
layoutA.columnCount - layoutB.columnCount ||
layoutA.id.localeCompare(layoutB.id);
export const normalizeBoardLayoutRoles = <
TLayout extends { id: string; breakpoint: number; columnCount: number; role?: LayoutRole },
>(
sourceLayouts: TLayout[],
): Array<TLayout & { role: LayoutRole }> => {
const mobileLayouts = sourceLayouts.filter((layout) => layout.role === "mobile");
const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base");
const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined;
const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined;
const orderedLayouts =
mobileLayout && baseLayout
? [
mobileLayout,
...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts),
baseLayout,
]
: sourceLayouts.toSorted(compareBoardLayouts);
let previousBreakpoint = -1;
return orderedLayouts.map((layout, index) => {
const remainingLayouts = orderedLayouts.length - index - 1;
const breakpoint =
index === 0
? 0
: Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint));
const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "custom";
previousBreakpoint = breakpoint;
return { ...layout, breakpoint, role };
});
};
@@ -1,6 +1,7 @@
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import { normalizeBoardLayoutRoles } from "@homarr/definitions";
import type { BoardSize, OldmarrConfig } from "@homarr/old-schema";
import { boardSizes, getBoardSizeName } from "@homarr/old-schema";
@@ -88,14 +89,19 @@ export const createBoardInsertCollection = (
{} as Record<BoardSize, string>,
);
const preparedLayouts = boardSizes.map((size) => ({
id: layoutMapping[size],
boardId: mappedBoard.id,
columnCount: mapColumnCount(board.config.settings.customization.gridstack, size),
breakpoint: mapBreakpoint(size),
name: getBoardSizeName(size),
}));
insertCollection.layouts.push(...preparedLayouts);
const importedLayouts = normalizeBoardLayoutRoles(
boardSizes.map((size) => ({
id: layoutMapping[size],
boardId: mappedBoard.id,
columnCount: mapColumnCount(board.config.settings.customization.gridstack, size),
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: mapBreakpoint(size),
name: getBoardSizeName(size),
})),
);
insertCollection.layouts.push(...importedLayouts);
const preparedSections = prepareSections(mappedBoard.id, { wrappers, categories });
@@ -113,7 +119,7 @@ export const createBoardInsertCollection = (
layoutMapping,
mappedBoard.id,
);
insertCollection.sectionLayouts.push(...placePreparedSections(preparedSections, preparedItems, preparedLayouts));
insertCollection.sectionLayouts.push(...placePreparedSections(preparedSections, preparedItems, importedLayouts));
preparedItems.forEach(({ layouts, ...item }) => {
insertCollection.items.push(item);
insertCollection.itemLayouts.push(...layouts);
@@ -1,9 +1,9 @@
import { generateResponsiveGridFor } from "@homarr/common";
import type { GridAlgorithmItem } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { BoardSize, OldmarrApp, OldmarrConfig, OldmarrWidget, SizedShape } from "@homarr/old-schema";
import { boardSizes } from "@homarr/old-schema";
import type { GridAlgorithmItem } from "../../../api/src/router/board/grid-algorithm";
import { generateResponsiveGridFor } from "../../../api/src/router/board/grid-algorithm";
import { mapColumnCount } from "../mappers/map-column-count";
import { mapApp, mapWidget } from "../mappers/map-item";
+54 -6
View File
@@ -1,9 +1,57 @@
import { describe, expect, it } from "vitest";
import { describe, expect, test } from "vitest";
import { responsiveBoardLayoutsSchema } from "./board";
import { containerSectionOptionsDefaults, sectionSchema } from "./shared";
const mobile = {
id: "mobile",
name: "Mobile",
columnCount: 3,
breakpoint: 0,
role: "mobile" as const,
};
const base = {
id: "base",
name: "Base",
columnCount: 12,
breakpoint: 768,
role: "base" as const,
};
describe("responsiveBoardLayoutsSchema", () => {
test("accepts protected layouts and custom layouts strictly between them", () => {
expect(
responsiveBoardLayoutsSchema.safeParse([
mobile,
{ id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" },
base,
]).success,
).toBe(true);
});
test.each([
{ name: "missing Mobile", layouts: [{ ...mobile, role: "custom" }, base] },
{ name: "removable Base replacement", layouts: [mobile, { ...base, role: "custom" }] },
{ name: "non-zero Mobile breakpoint", layouts: [{ ...mobile, breakpoint: 320 }, base] },
{
name: "custom layout at Base breakpoint",
layouts: [mobile, { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 768, role: "custom" }, base],
},
{
name: "duplicate layout ID",
layouts: [mobile, { ...base, id: mobile.id }],
},
{
name: "mobile sidebar",
layouts: [{ ...mobile, leftGutterColumnCount: 1 }, base],
},
])("rejects $name", ({ layouts }) => {
expect(responsiveBoardLayoutsSchema.safeParse(layouts).success).toBe(false);
});
});
describe("section behavior validation", () => {
it("normalizes a container to the current behavior defaults", () => {
test("normalizes a container to the current behavior defaults", () => {
const result = sectionSchema.parse({
id: "container",
kind: "container",
@@ -16,7 +64,7 @@ describe("section behavior validation", () => {
});
});
it("preserves configured container behavior", () => {
test("preserves configured container behavior", () => {
const result = sectionSchema.parse({
id: "container",
kind: "container",
@@ -44,7 +92,7 @@ describe("section behavior validation", () => {
});
});
it("normalizes the legacy dynamic kind to a container", () => {
test("normalizes the legacy dynamic kind to a container", () => {
expect(sectionSchema.parse({ id: "legacy", kind: "dynamic", layouts: [] })).toMatchObject({
id: "legacy",
kind: "container",
@@ -52,11 +100,11 @@ describe("section behavior validation", () => {
});
});
it("rejects the legacy category kind", () => {
test("rejects the legacy category kind", () => {
expect(() => sectionSchema.parse({ id: "legacy", kind: "category", layouts: [] })).toThrow();
});
it.each([
test.each([
{ xOffset: 0.5, yOffset: 0, width: 1, height: 1 },
{ xOffset: -1, yOffset: 0, width: 1, height: 1 },
{ xOffset: 0, yOffset: 0, width: 0, height: 1 },