feat: protect responsive board layouts
This commit is contained in:
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,
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user