refactor: simplify mobile board layouts

This commit is contained in:
ajnart committed 2026-08-14 11:32:09 +02:00
1 parent 7bbca3bd40
commit 02fe99d5aa
30 files changed
+75 -1430

No files matched your search

+1 -1
View File
@@ -80,7 +80,7 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
- Change board visibility
- Update board visual and behavior settings
- Set desktop and mobile home boards for the current user
- Configure global board defaults such as public home boards, automatic mobile layout mode, and status behavior
- Configure global board defaults such as public home boards and status behavior
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
-1
View File
@@ -129,7 +129,6 @@ export default async function Layout(props: {
board: {
homeBoardId: serverSettings.board.homeBoardId,
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
enableAutomaticMobileLayout: serverSettings.board.enableAutomaticMobileLayout,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
},
@@ -7,7 +7,7 @@ export const getFirstEmptyPosition = (
size: { width: number; height: number } = { width: 1, height: 1 },
) => {
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
const isOccupied = elements.some(
(element) =>
element.yOffset < yOffset + size.height &&
@@ -1,8 +1,9 @@
import { getCurrentLayout } from "@homarr/boards/context";
import type { Board } from "~/app/[locale]/boards/_types";
export interface MoveAndResizeItemInput {
itemId: string;
layoutId: string;
xOffset: number;
yOffset: number;
width: number;
@@ -10,8 +11,10 @@ export interface MoveAndResizeItemInput {
}
export const moveAndResizeItemCallback =
({ itemId, layoutId, ...layoutInput }: MoveAndResizeItemInput) =>
({ itemId, ...layoutInput }: MoveAndResizeItemInput) =>
(previous: Board): Board => {
const currentLayout = getCurrentLayout(previous);
return {
...previous,
items: previous.items.map((item) =>
@@ -20,7 +23,7 @@ export const moveAndResizeItemCallback =
: {
...item,
layouts: item.layouts.map((layout) =>
layout.layoutId !== layoutId
layout.layoutId !== currentLayout
? layout
: {
...layout,
@@ -1,8 +1,9 @@
import { getCurrentLayout } from "@homarr/boards/context";
import type { Board } from "~/app/[locale]/boards/_types";
export interface MoveItemToSectionInput {
itemId: string;
layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
@@ -11,8 +12,10 @@ export interface MoveItemToSectionInput {
}
export const moveItemToSectionCallback =
({ itemId, layoutId, ...layoutInput }: MoveItemToSectionInput) =>
({ itemId, ...layoutInput }: MoveItemToSectionInput) =>
(board: Board): Board => {
const currentLayout = getCurrentLayout(board);
return {
...board,
items: board.items.map((item) =>
@@ -21,7 +24,7 @@ export const moveItemToSectionCallback =
: {
...item,
layouts: item.layouts.map((layout) =>
layout.layoutId !== layoutId
layout.layoutId !== currentLayout
? layout
: {
...layout,
@@ -106,10 +106,6 @@ describe("get first empty position", () => {
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
});
test("does not place an item wider than the layout", () => {
expect(getFirstEmptyPosition([], 1, undefined, { width: 2, height: 1 })).toBeUndefined();
});
});
const createElementsFromLayout = (layout: string[][]) => {
const elements: (Pick<Item["layouts"][number], "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
@@ -1,4 +1,6 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import { moveAndResizeItemCallback } from "../move-and-resize-item";
import { BoardMockBuilder } from "./mocks/board-mock";
@@ -9,6 +11,8 @@ describe("moveItemToSectionCallback should move item in section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
xOffset: 20,
yOffset: 30,
@@ -21,7 +25,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
// Act
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(3);
@@ -39,6 +43,8 @@ describe("moveItemToSectionCallback should move item in section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
xOffset: 20,
yOffset: 30,
@@ -50,7 +56,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
// Act
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(2);
@@ -1,4 +1,6 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import { moveItemToSectionCallback } from "../move-item-to-section";
import { BoardMockBuilder } from "./mocks/board-mock";
@@ -9,6 +11,8 @@ describe("moveItemToSectionCallback should move item to section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
sectionId: "3",
xOffset: 20,
@@ -22,7 +26,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
// Act
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(3);
@@ -40,6 +44,8 @@ describe("moveItemToSectionCallback should move item to section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
sectionId: "3",
xOffset: 20,
@@ -52,7 +58,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
// Act
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(2);
@@ -1,23 +0,0 @@
.container {
container-type: inline-size;
}
.grid {
display: grid;
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
grid-auto-flow: row;
grid-auto-rows: min(calc((100cqw - 42px) / var(--mobile-column-count)), 12rem);
gap: 10px;
padding-bottom: 10px;
}
.item {
position: relative;
min-width: 0;
min-height: 0;
}
.item > :global(.grid-stack-item-content) {
width: 100%;
height: 100%;
}
@@ -1,56 +0,0 @@
"use client";
import type { CSSProperties, RefObject } from "react";
import { useMemo, useRef } from "react";
import { Box } from "@mantine/core";
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
import { ReadOnlyEditModeProvider } from "@homarr/boards/edit-mode";
import type { GridItemHTMLElement, GridStack as GridStackInstance } from "@homarr/gridstack";
import { BoardItemContent } from "../items/item-content";
import { SectionProvider } from "../sections/section-context";
import classes from "./mobile-board.module.css";
import { createMobileBoardItems, getMobileRootSection, mobileColumnCount } from "./mobile-layout";
export const MobileBoard = () => {
const board = useRequiredBoard();
const desktopLayout = getDesktopLayout(board);
const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]);
const rootSection = getMobileRootSection(board);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
const gridstackRef = useRef<GridStackInstance | null>(null);
return (
<ReadOnlyEditModeProvider>
<SectionProvider
value={{
section: rootSection,
items,
innerSections: [],
refs: { wrapper: wrapperRef, items: itemRefs, gridstack: gridstackRef },
}}
>
<Box ref={wrapperRef} className={classes.container}>
<Box
className={classes.grid}
style={{ "--mobile-column-count": mobileColumnCount } as CSSProperties}
data-mobile-board
>
{items.map((item) => (
<Box
key={item.id}
className={classes.item}
style={{ gridColumn: `span ${item.width}`, gridRow: `span ${item.height}` }}
data-mobile-board-item={item.id}
>
<BoardItemContent item={item} />
</Box>
))}
</Box>
</Box>
</SectionProvider>
</ReadOnlyEditModeProvider>
);
};
@@ -1,154 +0,0 @@
import { describe, expect, test } from "vitest";
import { CategorySectionMockBuilder } from "../items/actions/test/mocks/category-section-mock";
import { BoardMockBuilder } from "../items/actions/test/mocks/board-mock";
import { DynamicSectionMockBuilder } from "../items/actions/test/mocks/dynamic-section-mock";
import { EmptySectionMockBuilder } from "../items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "../items/actions/test/mocks/item-mock";
import { createMobileBoardItems, createMobileBoardPreviewItems, getMobileRootSection } from "./mobile-layout";
describe("createMobileBoardItems", () => {
test("selects the first root section in visual order", () => {
const board = new BoardMockBuilder().build();
board.sections.push(
new EmptySectionMockBuilder({ id: "stored-first", xOffset: 0, yOffset: 1 }).build(),
new EmptySectionMockBuilder({ id: "visually-first", xOffset: 1, yOffset: 0 }).build(),
new EmptySectionMockBuilder({ id: "visually-second", xOffset: 2, yOffset: 0 }).build(),
);
expect(getMobileRootSection(board).id).toBe("visually-first");
});
test("flattens categories and dynamic sections in visual order", () => {
const board = new BoardMockBuilder().build();
const desktopLayoutId = board.layouts.at(0)?.id;
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
const rootSection = new EmptySectionMockBuilder({ id: "root", yOffset: 0 }).build();
const category = new CategorySectionMockBuilder({ id: "category", yOffset: 1, collapsed: true }).build();
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
.addLayout({ layoutId: desktopLayoutId, parentSectionId: rootSection.id, xOffset: 0, yOffset: 1 })
.build();
const nestedDynamicSection = new DynamicSectionMockBuilder({ id: "nested-dynamic" })
.addLayout({ layoutId: desktopLayoutId, parentSectionId: dynamicSection.id, xOffset: 0, yOffset: 1 })
.build();
board.sections.push(rootSection, category, dynamicSection, nestedDynamicSection);
board.items.push(
new ItemMockBuilder({ id: "second" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, xOffset: 1, yOffset: 0 })
.build(),
new ItemMockBuilder({ id: "first" })
.addLayout({
layoutId: desktopLayoutId,
sectionId: rootSection.id,
xOffset: 0,
yOffset: 0,
width: 8,
height: 6,
})
.build(),
new ItemMockBuilder({ id: "inside-dynamic" })
.addLayout({ layoutId: desktopLayoutId, sectionId: dynamicSection.id })
.build(),
new ItemMockBuilder({ id: "inside-nested-dynamic" })
.addLayout({ layoutId: desktopLayoutId, sectionId: nestedDynamicSection.id })
.build(),
new ItemMockBuilder({ id: "inside-collapsed-category" })
.addLayout({ layoutId: desktopLayoutId, sectionId: category.id })
.build(),
);
const result = createMobileBoardItems(board, desktopLayoutId);
expect(result.map((item) => item.id)).toEqual([
"first",
"second",
"inside-dynamic",
"inside-nested-dynamic",
"inside-collapsed-category",
]);
expect(result[0]).toMatchObject({ width: 2, height: 3, xOffset: 0, yOffset: 0 });
});
test("uses the desktop layout and keeps orphaned items", () => {
const board = new BoardMockBuilder().build();
const legacyLayoutId = board.layouts.at(0)?.id;
if (!legacyLayoutId) throw new Error("Expected a legacy layout");
const desktopLayoutId = "desktop";
board.layouts.push({ id: desktopLayoutId, name: "Large", columnCount: 16, breakpoint: 1200 });
board.sections.push(new EmptySectionMockBuilder({ id: "root" }).build());
board.items.push(
new ItemMockBuilder({ id: "first" })
.addLayout({ layoutId: legacyLayoutId, sectionId: "root", yOffset: 2 })
.addLayout({ layoutId: desktopLayoutId, sectionId: "missing-section", yOffset: 0, width: 0, height: 0 })
.build(),
new ItemMockBuilder({ id: "second" })
.addLayout({ layoutId: legacyLayoutId, sectionId: "root", yOffset: 0 })
.addLayout({ layoutId: desktopLayoutId, sectionId: "root", yOffset: 1 })
.build(),
);
const result = createMobileBoardItems(board, desktopLayoutId);
expect(result.map((item) => item.id)).toEqual(["second", "first"]);
expect(result[1]).toMatchObject({ width: 1, height: 1 });
});
test("keeps useful desktop footprints within the two-by-three mobile limit", () => {
const board = new BoardMockBuilder().build();
const desktopLayoutId = board.layouts.at(0)?.id;
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
board.sections.push(rootSection);
board.items.push(
new ItemMockBuilder({ id: "two-by-two" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 2, height: 2 })
.build(),
new ItemMockBuilder({ id: "one-by-one" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, xOffset: 2, width: 1, height: 1 })
.build(),
);
expect(createMobileBoardItems(board, desktopLayoutId)).toMatchObject([
{ id: "two-by-two", width: 2, height: 2 },
{ id: "one-by-one", width: 1, height: 1 },
]);
});
});
describe("createMobileBoardPreviewItems", () => {
test("returns no tiles for an empty board", () => {
const board = new BoardMockBuilder().build();
const desktopLayoutId = board.layouts.at(0)?.id;
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
expect(createMobileBoardPreviewItems(board, desktopLayoutId)).toEqual([]);
});
test("uses the production mobile order, footprint, kind, app ID, and custom title", () => {
const board = new BoardMockBuilder().build();
const desktopLayoutId = board.layouts.at(0)?.id;
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
board.sections.push(rootSection);
board.items.push(
new ItemMockBuilder({ id: "wide", kind: "notebook" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 8, height: 6, yOffset: 1 })
.build(),
new ItemMockBuilder({ id: "first", kind: "app" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 1, height: 1, yOffset: 0 })
.build(),
);
const appItem = board.items.find((item) => item.id === "first");
if (!appItem || appItem.kind !== "app") throw new Error("Expected app board item");
appItem.options.appId = "actual-app";
const wideItem = board.items.find((item) => item.id === "wide");
if (!wideItem) throw new Error("Expected wide board item");
wideItem.advancedOptions.title = "Notes";
expect(createMobileBoardPreviewItems(board, desktopLayoutId)).toEqual([
{ id: "first", kind: "app", appId: "actual-app", width: 1, height: 1, title: null },
{ id: "wide", kind: "notebook", appId: null, width: 2, height: 3, title: "Notes" },
]);
});
});
@@ -1,127 +0,0 @@
import type { Board, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types";
export const mobileColumnCount = 2;
const mobileMaxHeight = 3;
type PositionedElement = DynamicSectionItem | SectionItem;
type PositionedLayout = {
layoutId: string;
xOffset: number;
yOffset: number;
width: number;
height: number;
};
const comparePosition = (elementA: PositionedElement, elementB: PositionedElement) =>
elementA.yOffset - elementB.yOffset || elementA.xOffset - elementB.xOffset;
const getMobileRootSections = (board: Board) =>
board.sections
.filter((section) => section.kind !== "dynamic")
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset || sectionA.xOffset - sectionB.xOffset);
export const getMobileRootSection = (board: Board) =>
getMobileRootSections(board)[0] ?? {
id: "mobile",
kind: "empty" as const,
xOffset: 0,
yOffset: 0,
};
const getLayout = <TLayout extends PositionedLayout>(
layouts: TLayout[],
desktopLayoutId: string,
layoutPriority: Map<string, number>,
) =>
layouts.find((layout) => layout.layoutId === desktopLayoutId) ??
layouts.toSorted(
(layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1),
)[0];
export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] => {
const rootSections = getMobileRootSections(board);
const defaultSectionId = rootSections[0]?.id ?? "mobile";
const layoutPriority = new Map(
board.layouts
.toSorted(
(layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint || layoutA.columnCount - layoutB.columnCount,
)
.map((layout, index) => [layout.id, index]),
);
const items = board.items.map(({ layouts, ...item }, index): SectionItem => {
const layout =
getLayout(layouts, desktopLayoutId, layoutPriority) ??
({
layoutId: desktopLayoutId,
xOffset: 0,
yOffset: index,
width: 1,
height: 1,
sectionId: defaultSectionId,
} satisfies ItemLayout);
return { ...item, ...layout, type: "item" };
});
const dynamicSections = board.sections
.filter((section) => section.kind === "dynamic")
.map(({ layouts, ...section }): DynamicSectionItem | null => {
const layout = getLayout(layouts, desktopLayoutId, layoutPriority);
return layout ? { ...section, ...layout, type: "section" } : null;
})
.filter((section) => section !== null);
const elementsBySection = new Map<string, PositionedElement[]>();
for (const element of [...items, ...dynamicSections]) {
const parentSectionId = element.type === "item" ? element.sectionId : element.parentSectionId;
const elements = elementsBySection.get(parentSectionId) ?? [];
elements.push(element);
elementsBySection.set(parentSectionId, elements);
}
const result: SectionItem[] = [];
const visitedItems = new Set<string>();
const visitedSections = new Set<string>();
const appendSectionItems = (sectionId: string) => {
if (visitedSections.has(sectionId)) return;
visitedSections.add(sectionId);
for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) {
if (element.type === "section") {
appendSectionItems(element.id);
continue;
}
if (!visitedItems.has(element.id)) {
visitedItems.add(element.id);
result.push(element);
}
}
};
rootSections.forEach((section) => appendSectionItems(section.id));
dynamicSections.toSorted(comparePosition).forEach((section) => appendSectionItems(section.id));
items.toSorted(comparePosition).forEach((item) => {
if (!visitedItems.has(item.id)) result.push(item);
});
return result.map((item, index) => ({
...item,
xOffset: 0,
yOffset: index,
width: Math.max(1, Math.min(item.width, mobileColumnCount)),
height: Math.max(1, Math.min(item.height, mobileMaxHeight)),
}));
};
export const createMobileBoardPreviewItems = (board: Board, desktopLayoutId: string) =>
createMobileBoardItems(board, desktopLayoutId).map((item) => ({
id: item.id,
kind: item.kind,
appId: item.kind === "app" && typeof item.options.appId === "string" ? item.options.appId : null,
width: item.width,
height: item.height,
title: item.advancedOptions.title,
}));
@@ -1,44 +0,0 @@
.panel {
width: min(390px, calc(100vw - var(--mantine-spacing-xl)));
max-height: min(70vh, 700px);
overflow: hidden;
}
.viewport {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
}
.grid {
display: grid;
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
grid-auto-flow: row;
grid-auto-rows: 42px;
gap: var(--mantine-spacing-xs);
}
.item {
display: flex;
min-width: 0;
align-items: center;
justify-content: center;
border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
}
.itemContent {
min-width: 0;
}
.appIcon {
width: 14px;
height: 14px;
}
@media (prefers-reduced-motion: reduce) {
.panel,
.item {
scroll-behavior: auto;
transition: none;
}
}
@@ -1,128 +0,0 @@
"use client";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import { ActionIcon, Affix, Button, Center, Group, Paper, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconDeviceMobile, IconX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { useIsMobileBoard } from "../use-mobile-board";
import classes from "./mobile-preview.module.css";
import { createMobileBoardPreviewItems, mobileColumnCount } from "./mobile-layout";
export const MobileBoardPreview = () => {
const [isEditMode] = useEditMode();
const [isExpanded, setIsExpanded] = useState(false);
const isMobileBoard = useIsMobileBoard();
const { enableAutomaticMobileLayout } = useSettings();
const t = useI18n();
useEffect(() => {
if (!isEditMode || isMobileBoard || !enableAutomaticMobileLayout) {
setIsExpanded(false);
}
}, [enableAutomaticMobileLayout, isEditMode, isMobileBoard]);
if (!isEditMode || isMobileBoard || !enableAutomaticMobileLayout) return null;
return (
<Affix position={{ bottom: "var(--mantine-spacing-md)", left: "var(--mantine-spacing-md)" }} zIndex={200}>
{isExpanded ? (
<Paper className={classes.panel} radius="md" shadow="md" data-mobile-board-preview>
<Group justify="space-between" wrap="nowrap" p="sm">
<Group gap="xs" wrap="nowrap">
<IconDeviceMobile size={20} aria-hidden />
<Text fw={600}>{t("board.mobilePreview.title")}</Text>
</Group>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setIsExpanded(false)}
aria-label={t("board.mobilePreview.collapse")}
>
<IconX size={18} />
</ActionIcon>
</Group>
<MobileBoardPreviewMap />
</Paper>
) : (
<Button
leftSection={<IconDeviceMobile size={20} />}
onClick={() => setIsExpanded(true)}
data-mobile-board-preview-toggle
>
{t("board.mobilePreview.action")}
</Button>
)}
</Affix>
);
};
const MobileBoardPreviewMap = () => {
const board = useRequiredBoard();
const desktopLayout = getDesktopLayout(board);
const items = useMemo(() => createMobileBoardPreviewItems(board, desktopLayout.id), [board, desktopLayout.id]);
const appIds = useMemo(
() => [...new Set(items.flatMap((item) => (item.appId === null ? [] : [item.appId])))],
[items],
);
const { data: apps = [] } = clientApi.app.byIds.useQuery(appIds, { enabled: appIds.length > 0 });
const appsById = useMemo(() => new Map(apps.map((app) => [app.id, app])), [apps]);
const t = useI18n();
return (
<ScrollArea.Autosize className={classes.viewport} mah="calc(min(70vh, 700px) - 52px)" p="sm">
{items.length === 0 ? (
<Center mih={160}>
<Stack gap={4} align="center">
<IconDeviceMobile size={28} color="var(--mantine-color-dimmed)" aria-hidden />
<Text size="sm" c="dimmed" ta="center">
{t("board.mobilePreview.empty")}
</Text>
</Stack>
</Center>
) : (
<div
className={classes.grid}
style={{ "--mobile-column-count": mobileColumnCount } as CSSProperties}
data-mobile-board-preview-grid
>
{items.map((item) => {
const WidgetIcon = widgetCatalogIcons[item.kind];
const app = item.appId === null ? undefined : appsById.get(item.appId);
const label = app?.name ?? (item.title?.trim() || t(`widget.${item.kind}.name`));
return (
<div
key={item.id}
className={classes.item}
style={{ gridColumn: `span ${item.width}`, gridRow: `span ${item.height}` }}
data-mobile-board-preview-item={item.id}
>
<Group className={classes.itemContent} gap={6} wrap="nowrap" px="xs">
<ThemeIcon variant="light" size="sm" data-mobile-board-preview-app-icon={app?.id}>
{app ? (
<MaskedOrNormalImage imageUrl={app.iconUrl} hasColor={false} alt="" className={classes.appIcon} />
) : (
<WidgetIcon size={14} aria-hidden />
)}
</ThemeIcon>
<Text size="xs" fw={500} truncate title={label}>
{label}
</Text>
</Group>
</div>
);
})}
</div>
)}
</ScrollArea.Autosize>
);
};
@@ -1,10 +1,10 @@
import { useCallback } from "react";
import { getCurrentLayout } from "@homarr/boards/context";
import { useUpdateBoard } from "@homarr/boards/updater";
interface MoveAndResizeInnerSection {
innerSectionId: string;
layoutId: string;
xOffset: number;
yOffset: number;
width: number;
@@ -12,7 +12,6 @@ interface MoveAndResizeInnerSection {
}
interface MoveInnerSectionToSection {
innerSectionId: string;
layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
@@ -24,7 +23,7 @@ export const useSectionActions = () => {
const { updateBoard } = useUpdateBoard();
const moveAndResizeInnerSection = useCallback(
({ innerSectionId, layoutId, ...positionProps }: MoveAndResizeInnerSection) => {
({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((section) => {
@@ -32,10 +31,12 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "container") return section;
const currentLayout = getCurrentLayout(previous);
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.layoutId !== layoutId) return layout;
if (layout.layoutId !== currentLayout) return layout;
return {
...layout,
...positionProps,
@@ -49,7 +50,7 @@ export const useSectionActions = () => {
);
const moveInnerSectionToSection = useCallback(
({ innerSectionId, layoutId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
updateBoard((previous) => {
return {
...previous,
@@ -58,10 +59,12 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "container") return section;
const currentLayout = getCurrentLayout(previous);
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.layoutId !== layoutId) return layout;
if (layout.layoutId !== currentLayout) return layout;
return {
...layout,
...positionProps,
@@ -1,26 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import { useMediaQuery } from "@mantine/hooks";
import { useSettings } from "@homarr/settings";
export const mobileBoardMediaQuery = "(max-width: 48em)";
const InitialMobileBoardContext = createContext<boolean | undefined>(undefined);
export const MobileBoardViewportProvider = ({
initialIsMobile,
children,
}: PropsWithChildren<{ initialIsMobile: boolean }>) => (
<InitialMobileBoardContext.Provider value={initialIsMobile}>{children}</InitialMobileBoardContext.Provider>
);
export const useIsMobileBoard = () => {
const initialIsMobile = useContext(InitialMobileBoardContext);
const { enableAutomaticMobileLayout } = useSettings();
const isMobileViewport = useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true });
return enableAutomaticMobileLayout && isMobileViewport;
};
-339
View File
@@ -1,339 +0,0 @@
import { chromium, devices, expect } from "@playwright/test";
import { createId } from "@paralleldrive/cuid2";
import { parse, stringify } from "superjson";
import { describe, test } from "vitest";
import { eq } from "drizzle-orm";
import * as sqliteSchema from "../packages/db/schema/sqlite";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import type { SqliteDatabase } from "./shared/e2e-db";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const credentials = {
username: "admin",
password: "Comp(exP4sswOrd",
};
const boardName = "mobile-e2e";
const seedBoardAsync = async (db: SqliteDatabase, userId: string) => {
const boardId = createId();
const desktopLayoutId = createId();
const mobileLayoutId = createId();
const sectionId = createId();
const emptySectionId = createId();
const notebookId = createId();
const appItemId = createId();
const appId = createId();
await db.insert(sqliteSchema.boards).values({
id: boardId,
name: boardName,
creatorId: userId,
});
await db.insert(sqliteSchema.layouts).values([
{
id: desktopLayoutId,
name: "Desktop",
boardId,
columnCount: 12,
breakpoint: 1200,
},
{
id: mobileLayoutId,
name: "Mobile",
boardId,
columnCount: 4,
breakpoint: 0,
},
]);
await db.insert(sqliteSchema.sections).values([
{
id: sectionId,
boardId,
kind: "category",
name: "Dashboard",
xOffset: 0,
yOffset: 0,
},
{
id: emptySectionId,
boardId,
kind: "empty",
xOffset: 0,
yOffset: 1,
},
]);
await db.insert(sqliteSchema.apps).values({
id: appId,
name: "Media Server",
iconUrl: "/favicon.ico",
});
await db.insert(sqliteSchema.items).values([
{
id: notebookId,
boardId,
kind: "notebook",
options: stringify({ content: "<p>Normal notebook content</p>" }),
advancedOptions: stringify({}),
},
{
id: appItemId,
boardId,
kind: "app",
options: stringify({ appId }),
advancedOptions: stringify({}),
},
]);
await db.insert(sqliteSchema.itemLayouts).values([
{
itemId: notebookId,
layoutId: desktopLayoutId,
sectionId,
xOffset: 0,
yOffset: 0,
width: 2,
height: 2,
},
{
itemId: appItemId,
layoutId: desktopLayoutId,
sectionId,
xOffset: 2,
yOffset: 0,
width: 1,
height: 1,
},
{
itemId: notebookId,
layoutId: mobileLayoutId,
sectionId,
xOffset: 0,
yOffset: 0,
width: 4,
height: 4,
},
{
itemId: appItemId,
layoutId: mobileLayoutId,
sectionId,
xOffset: 0,
yOffset: 4,
width: 2,
height: 2,
},
]);
await db
.update(sqliteSchema.users)
.set({
homeBoardId: boardId,
mobileHomeBoardId: boardId,
completedBoardTour: true,
})
.where(eq(sqliteSchema.users.id, userId));
return { appId };
};
describe("Automatic mobile board", () => {
test("enables automatic mobile layout for a fresh database", async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
const container = await createHomarrContainer({
mounts: {
"/appdata": localMountPath,
},
}).start();
try {
const boardSettings = await db.query.serverSettings.findFirst({
where: eq(sqliteSchema.serverSettings.settingKey, "board"),
});
if (!boardSettings) throw new Error("Expected board server settings to be seeded");
expect(parse<{ enableAutomaticMobileLayout: boolean }>(boardSettings.value).enableAutomaticMobileLayout).toBe(
true,
);
} finally {
await container.stop();
}
}, 90_000);
test("squeezes unchanged widgets into a read-only two-column grid", async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
const { userId } = await seedAdminUserAsync(db, credentials);
const { appId } = await seedBoardAsync(db, userId);
await db.insert(sqliteSchema.serverSettings).values({
settingKey: "board",
value: stringify({
homeBoardId: null,
mobileHomeBoardId: null,
enableAutomaticMobileLayout: true,
enableStatusByDefault: true,
forceDisableStatus: false,
}),
});
const container = await createHomarrContainer({
environment: {
AUTH_PROVIDERS: "credentials",
},
mounts: {
"/appdata": localMountPath,
},
}).start();
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(7575)}`;
const browser = await chromium.launch();
const loginContext = await browser.newContext();
try {
const loginPage = await loginContext.newPage();
await loginPage.goto(`${baseUrl}/auth/login`);
await loginPage.getByLabel("Username").fill(credentials.username);
await loginPage.locator("#password").fill(credentials.password);
await loginPage.locator("button[type='submit']").click();
await loginPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 });
await loginPage.goto(`${baseUrl}/boards/${boardName}`);
await expect(loginPage.locator("[data-mobile-board-preview-toggle]")).toHaveCount(0);
await loginPage.getByRole("button", { name: "Toggle board edit mode", exact: true }).click();
const previewToggle = loginPage.getByRole("button", { name: "Mobile preview", exact: true });
await expect(previewToggle).toBeVisible();
expect(await previewToggle.evaluate((element) => element.getBoundingClientRect().left)).toBeLessThan(32);
await previewToggle.click();
await expect(loginPage.locator("[data-mobile-board-preview]")).toBeVisible();
await expect(loginPage.locator("[data-mobile-board-preview-item]")).toHaveCount(2);
await expect(loginPage.getByText("Media Server", { exact: true })).toBeVisible();
await expect(loginPage.locator(`[data-mobile-board-preview-app-icon="${appId}"] img`)).toHaveAttribute(
"src",
"/favicon.ico",
);
await loginPage
.locator("header button")
.filter({ has: loginPage.locator("svg.tabler-icon-plus") })
.click();
await loginPage.getByRole("menuitem", { name: "New item", exact: true }).click();
await loginPage.getByPlaceholder("Filter items...").fill("Weather");
await loginPage.getByPlaceholder("Filter items...").press("Enter");
await expect(loginPage.locator("[data-mobile-board-preview-item]")).toHaveCount(3);
await loginPage.getByRole("button", { name: "Cancel", exact: true }).click();
await loginPage.getByRole("button", { name: "Collapse mobile preview", exact: true }).click();
await expect(loginPage.locator("[data-mobile-board-preview]")).toHaveCount(0);
await expect(previewToggle).toBeVisible();
const storageState = await loginContext.storageState();
const mobileContext = await browser.newContext({
storageState,
viewport: { width: 390, height: 844 },
userAgent: devices["iPhone 13"].userAgent,
hasTouch: true,
isMobile: true,
});
const mobilePage = await mobileContext.newPage();
await mobilePage.goto(`${baseUrl}/boards/${boardName}`);
const mobileBoard = mobilePage.locator("[data-mobile-board]");
const items = mobilePage.locator("[data-mobile-board-item]");
await expect(mobileBoard).toBeVisible({ timeout: 15_000 });
await expect(mobilePage.locator("[data-mobile-board-preview-toggle]")).toHaveCount(0);
await expect(items).toHaveCount(2);
await expect(mobilePage.locator(".grid-stack-item")).toHaveCount(0);
await expect(mobilePage.getByText("Normal notebook content")).toBeVisible();
await expect(mobilePage.getByRole("button", { name: "Toggle board edit mode", exact: true })).toHaveCount(0);
await expect(mobilePage.getByRole("button", { name: /open widget details/i })).toHaveCount(0);
await expect(mobilePage.getByRole("button", { name: /actions for/i })).toHaveCount(0);
await expect(mobilePage.locator("[data-mobile-generic-summary], [data-mobile-display-mode]")).toHaveCount(0);
const metrics = await mobilePage.evaluate(() => {
const grid = document.querySelector<HTMLElement>("[data-mobile-board]");
const itemElements = Array.from(document.querySelectorAll<HTMLElement>("[data-mobile-board-item]"));
if (!grid || itemElements.length !== 2) throw new Error("Expected mobile grid and two items");
const gridStyle = getComputedStyle(grid);
const first = itemElements[0]?.getBoundingClientRect();
const second = itemElements[1]?.getBoundingClientRect();
if (!first || !second) throw new Error("Expected item bounds");
return {
columnCount: gridStyle.gridTemplateColumns.split(" ").filter(Boolean).length,
firstToSecondWidthRatio: first.width / second.width,
firstToSecondHeightRatio: first.height / second.height,
horizontalOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
expect(metrics.columnCount).toBe(2);
expect(metrics.firstToSecondWidthRatio).toBeGreaterThan(1.8);
expect(metrics.firstToSecondHeightRatio).toBeGreaterThan(1.8);
expect(metrics.horizontalOverflow).toBeLessThanOrEqual(0);
await mobileContext.close();
const tabletContext = await browser.newContext({
storageState,
viewport: { width: 768, height: 1024 },
userAgent: devices["iPad (gen 7)"].userAgent,
hasTouch: true,
isMobile: true,
});
const tabletPage = await tabletContext.newPage();
await tabletPage.goto(`${baseUrl}/boards/${boardName}`);
await expect(tabletPage.locator("[data-mobile-board]")).toBeVisible({ timeout: 15_000 });
await expect(tabletPage.locator("[data-mobile-board-item]")).toHaveCount(2);
await expect(tabletPage.locator("[data-mobile-board-preview-toggle]")).toHaveCount(0);
await expect(tabletPage.getByRole("button", { name: "Toggle board edit mode", exact: true })).toHaveCount(0);
await tabletContext.close();
} finally {
await loginContext.close();
await browser.close();
await container.stop();
}
}, 180_000);
test("keeps preview and generated mobile grid disabled in legacy mode", async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
const { userId } = await seedAdminUserAsync(db, credentials);
await seedBoardAsync(db, userId);
const container = await createHomarrContainer({
environment: {
AUTH_PROVIDERS: "credentials",
},
mounts: {
"/appdata": localMountPath,
},
}).start();
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(7575)}`;
const browser = await chromium.launch();
const desktopContext = await browser.newContext();
try {
const desktopPage = await desktopContext.newPage();
await desktopPage.goto(`${baseUrl}/auth/login`);
await desktopPage.getByLabel("Username").fill(credentials.username);
await desktopPage.locator("#password").fill(credentials.password);
await desktopPage.locator("button[type='submit']").click();
await desktopPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 });
await desktopPage.goto(`${baseUrl}/boards/${boardName}`);
await desktopPage.getByRole("button", { name: "Toggle board edit mode", exact: true }).click();
await expect(desktopPage.locator("[data-mobile-board-preview-toggle]")).toHaveCount(0);
const legacyContext = await browser.newContext({
storageState: await desktopContext.storageState(),
viewport: { width: 390, height: 844 },
userAgent: devices["iPhone 13"].userAgent,
hasTouch: true,
isMobile: true,
});
const legacyPage = await legacyContext.newPage();
await legacyPage.goto(`${baseUrl}/boards/${boardName}`);
await expect(legacyPage.locator("[data-mobile-board]")).toHaveCount(0);
await expect(legacyPage.locator(".grid-stack-item")).toHaveCount(2);
await expect(legacyPage.getByRole("button", { name: "Toggle board edit mode", exact: true })).toBeVisible();
await legacyContext.close();
} finally {
await desktopContext.close();
await browser.close();
await container.stop();
}
}, 180_000);
});
+16 -10
View File
@@ -5,10 +5,10 @@ import { and, eq, inArray } from "@homarr/db";
import {
getServerSettingByKeyAsync,
getServerSettingsAsync,
mergeServerSettingByKeyAsync,
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "@homarr/db/queries";
import { boards } from "@homarr/db/schema";
import { boards, serverSettings } from "@homarr/db/schema";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import { settingsInitSchema } from "@homarr/validation/settings";
@@ -19,7 +19,6 @@ import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
const boardServerSettingsSchema = z.object({
homeBoardId: z.string().nullable(),
mobileHomeBoardId: z.string().nullable(),
enableAutomaticMobileLayout: z.boolean(),
enableStatusByDefault: z.boolean(),
forceDisableStatus: z.boolean(),
}) satisfies z.ZodType<ServerSettings["board"]>;
@@ -40,7 +39,7 @@ export const serverSettingsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Get global board defaults, including desktop/mobile home board IDs, automatic mobile layout mode, and status behavior. Requires admin permission",
"Get global board defaults, including desktop/mobile home board IDs and status behavior. Requires admin permission",
},
})
.input(z.void())
@@ -55,7 +54,7 @@ export const serverSettingsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableAutomaticMobileLayout, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards returned by the public-board listing tool or API, using each board's id field, or be null",
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
},
})
.input(boardServerSettingsUpdateSchema)
@@ -82,9 +81,17 @@ export const serverSettingsRouter = createTRPCRouter({
const current = await getServerSettingByKeyAsync(ctx.db, "board");
const next = { ...current, ...input };
await mergeServerSettingByKeyAsync(ctx.db, "board", next, input);
const existing = await ctx.db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, "board"),
});
return await getServerSettingByKeyAsync(ctx.db, "board");
if (existing) {
await updateServerSettingByKeyAsync(ctx.db, "board", next);
} else {
await insertServerSettingByKeyAsync(ctx.db, "board", next);
}
return next;
}),
saveSettings: permissionRequiredProcedure
.requiresPermission("admin")
@@ -96,11 +103,10 @@ export const serverSettingsRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
const next = {
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
...current,
...input.value,
} as ServerSettings[keyof ServerSettings];
await mergeServerSettingByKeyAsync(ctx.db, input.settingsKey, next, input.value);
} as ServerSettings[keyof ServerSettings]);
}),
initSettings: onboardingProcedure
.requiresStep("settings")
@@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { boards, layouts, serverSettings } from "@homarr/db/schema";
import { boards, serverSettings } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
@@ -56,33 +56,6 @@ describe("getAll server settings", () => {
});
describe("saveSettings", () => {
test("saveSettings should persist settings when defaults are not seeded", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await caller.saveSettings({
settingsKey: "board",
value: {
enableAutomaticMobileLayout: false,
},
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
{
settingKey: "board",
value: stringify({
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
}),
},
]);
});
test("saveSettings should update settings and return true when it updated only one", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
@@ -116,33 +89,6 @@ describe("saveSettings", () => {
},
]);
});
test("saveSettings should preserve concurrent partial updates", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await Promise.all([
caller.saveSettings({
settingsKey: "analytics",
value: { enableGeneral: false },
}),
caller.saveSettings({
settingsKey: "analytics",
value: { instanceId: "concurrent-instance" },
}),
]);
await expect(caller.getAll()).resolves.toMatchObject({
analytics: {
enableGeneral: false,
instanceId: "concurrent-instance",
},
});
});
});
describe("board settings API", () => {
@@ -157,36 +103,6 @@ describe("board settings API", () => {
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
});
test("getBoardSettings should backfill the automatic layout default for legacy values", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "legacy", isPublic: false });
await db.insert(layouts).values([
{ id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 },
{ id: createId(), boardId, name: "Mobile", columnCount: 4, breakpoint: 480 },
]);
const legacyBoardSettings = {
homeBoardId: defaultServerSettings.board.homeBoardId,
mobileHomeBoardId: defaultServerSettings.board.mobileHomeBoardId,
enableStatusByDefault: defaultServerSettings.board.enableStatusByDefault,
forceDisableStatus: defaultServerSettings.board.forceDisableStatus,
};
await db.insert(serverSettings).values({
settingKey: "board",
value: stringify(legacyBoardSettings),
});
await expect(caller.getBoardSettings()).resolves.toStrictEqual({
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
});
});
test("updateBoardSettings should insert settings when defaults are not persisted", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
@@ -229,7 +145,6 @@ describe("board settings API", () => {
await caller.updateBoardSettings({ homeBoardId: boardId });
const result = await caller.updateBoardSettings({
mobileHomeBoardId: boardId,
enableAutomaticMobileLayout: false,
enableStatusByDefault: false,
});
@@ -237,7 +152,6 @@ describe("board settings API", () => {
...defaultServerSettings.board,
homeBoardId: boardId,
mobileHomeBoardId: boardId,
enableAutomaticMobileLayout: false,
enableStatusByDefault: false,
});
const dbSettings = await db.select().from(serverSettings);
@@ -249,25 +163,6 @@ describe("board settings API", () => {
]);
});
test("updateBoardSettings should preserve concurrent partial updates", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await Promise.all([
caller.updateBoardSettings({ forceDisableStatus: true }),
caller.updateBoardSettings({ enableStatusByDefault: false }),
]);
await expect(caller.getBoardSettings()).resolves.toMatchObject({
forceDisableStatus: true,
enableStatusByDefault: false,
});
});
test("updateBoardSettings should reject private home boards", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
-1
View File
@@ -25,7 +25,6 @@
},
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0",
"react": "catalog:",
"react-dom": "catalog:"
},
-47
View File
@@ -1,47 +0,0 @@
import { describe, expect, test } from "vitest";
import type { RouterOutputs } from "@homarr/api";
import { getBoardLayouts, getCurrentLayout, getDesktopLayout } from "./context";
const createBoard = () =>
({
layouts: [
{ id: "small", name: "Small", columnCount: 4, breakpoint: 0 },
{ id: "medium", name: "Medium", columnCount: 8, breakpoint: 768 },
{ id: "large", name: "Large", columnCount: 12, breakpoint: 1200 },
],
}) as RouterOutputs["board"]["getBoardByName"];
describe("board layout selection", () => {
test("automatic mode always uses the desktop layout", () => {
const board = createBoard();
expect(getDesktopLayout(board).id).toBe("large");
expect(getCurrentLayout(board, true, 390)).toBe("large");
});
test.each([
[390, "small"],
[768, "medium"],
[1024, "medium"],
[1440, "large"],
])("legacy mode selects the matching responsive layout at %spx", (viewportWidth, expectedLayoutId) => {
expect(getCurrentLayout(createBoard(), false, viewportWidth)).toBe(expectedLayoutId);
});
test("legacy mode falls back to the desktop layout before the client viewport is known", () => {
expect(getCurrentLayout(createBoard(), false, undefined)).toBe("large");
});
test("rejects boards without a layout", () => {
const board = { ...createBoard(), layouts: [] };
expect(() => getDesktopLayout(board)).toThrow("Board must have a layout");
expect(() => getCurrentLayout(board, false, 390)).toThrow("Board must have a layout");
});
test("keeps every saved layout available for reversible mutations", () => {
expect(getBoardLayouts(createBoard())).toEqual(["small", "medium", "large"]);
});
});
-9
View File
@@ -6,21 +6,12 @@ import { useDisclosure } from "@mantine/hooks";
const EditModeContext = createContext<ReturnType<typeof useDisclosure> | null>(null);
const noop = () => undefined;
const readOnlyEditMode = [false, { open: noop, close: noop, toggle: noop, set: noop }] satisfies ReturnType<
typeof useDisclosure
>;
export const EditModeProvider = ({ children }: PropsWithChildren) => {
const editModeDisclosure = useDisclosure(false);
return <EditModeContext.Provider value={editModeDisclosure}>{children}</EditModeContext.Provider>;
};
export const ReadOnlyEditModeProvider = ({ children }: PropsWithChildren) => (
<EditModeContext.Provider value={readOnlyEditMode}>{children}</EditModeContext.Provider>
);
export const useEditMode = () => {
const context = useContext(EditModeContext);
+1 -1
View File
@@ -5,7 +5,7 @@ import { objectEntries } from "@homarr/common";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import type { HomarrDatabase, HomarrDatabaseMysql, HomarrDatabasePostgresql } from "./driver";
import { schema } from "./schema";
import * as schema from "./schema";
type TableKey = {
[K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never;
-170
View File
@@ -1,170 +0,0 @@
import { stringify } from "superjson";
import { describe, expect, test } from "vitest";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import {
getServerSettingByKeyAsync,
getServerSettingsAsync,
shouldEnableAutomaticMobileLayoutForUpgrade,
} from "@homarr/db/queries";
import { boards, layouts, serverSettings } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { defaultServerSettings } from "@homarr/server-settings";
import { seedServerSettingsAsync } from "./seed";
describe("automatic mobile layout upgrade default", () => {
test("enables automatic mode for a new instance", () => {
expect(shouldEnableAutomaticMobileLayoutForUpgrade([])).toBe(true);
});
test("enables automatic mode when every existing board has only its canonical layout", () => {
expect(
shouldEnableAutomaticMobileLayoutForUpgrade([
{ boardId: "first", name: "Base", breakpoint: 0 },
{ boardId: "second", name: "Base", breakpoint: 0 },
]),
).toBe(true);
});
test("preserves legacy mode when a board has multiple responsive layouts", () => {
expect(
shouldEnableAutomaticMobileLayoutForUpgrade([
{ boardId: "board", name: "Base", breakpoint: 0 },
{ boardId: "board", name: "Mobile", breakpoint: 480 },
]),
).toBe(false);
});
test("preserves legacy mode when a board has duplicate canonical layouts", () => {
expect(
shouldEnableAutomaticMobileLayoutForUpgrade([
{ boardId: "board", name: "Base", breakpoint: 0 },
{ boardId: "board", name: "Base", breakpoint: 0 },
]),
).toBe(false);
});
test.each([
{ boardId: "board", name: "Phone", breakpoint: 0 },
{ boardId: "board", name: "Base", breakpoint: 480 },
])("preserves legacy mode for a noncanonical saved layout $name/$breakpoint", (existingLayout) => {
expect(shouldEnableAutomaticMobileLayoutForUpgrade([existingLayout])).toBe(false);
});
test("uses the upgrade heuristic when migrations and seeding are skipped", async () => {
const db = createDb();
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false });
await db.insert(layouts).values([
{ id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 },
{ id: createId(), boardId, name: "Phone", columnCount: 4, breakpoint: 480 },
]);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toMatchObject({
enableAutomaticMobileLayout: false,
});
await expect(getServerSettingsAsync(db)).resolves.toMatchObject({
board: {
enableAutomaticMobileLayout: false,
},
});
});
test("persists the enabled default when a fresh instance is seeded repeatedly", async () => {
const db = createDb();
await seedServerSettingsAsync(db);
await seedServerSettingsAsync(db);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual(defaultServerSettings.board);
});
test("backfills the enabled default for canonical layouts", async () => {
const db = createDb();
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false });
await db.insert(layouts).values({
id: createId(),
boardId,
name: "Base",
columnCount: 12,
breakpoint: 0,
});
await db.insert(serverSettings).values({
settingKey: "board",
value: stringify({
homeBoardId: null,
mobileHomeBoardId: null,
enableStatusByDefault: true,
forceDisableStatus: false,
}),
});
await seedServerSettingsAsync(db);
await seedServerSettingsAsync(db);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual(defaultServerSettings.board);
});
test("does not override an explicit disabled choice", async () => {
const db = createDb();
await db.insert(serverSettings).values({
settingKey: "board",
value: stringify({
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
}),
});
await seedServerSettingsAsync(db);
await seedServerSettingsAsync(db);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual({
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
});
});
test("backfills legacy responsive instances once without overriding an explicit choice", async () => {
const db = createDb();
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false });
await db.insert(layouts).values([
{ id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 },
{ id: createId(), boardId, name: "Phone", columnCount: 4, breakpoint: 480 },
]);
await db.insert(serverSettings).values({
settingKey: "board",
value: stringify({
homeBoardId: null,
mobileHomeBoardId: null,
enableStatusByDefault: true,
forceDisableStatus: false,
}),
});
await seedServerSettingsAsync(db);
await seedServerSettingsAsync(db);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual({
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
});
await db
.update(serverSettings)
.set({
value: stringify({
...defaultServerSettings.board,
enableAutomaticMobileLayout: true,
}),
})
.where(eq(serverSettings.settingKey, "board"));
await seedServerSettingsAsync(db);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual(defaultServerSettings.board);
});
});
+4 -19
View File
@@ -21,7 +21,6 @@ import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
import {
getServerSettingByKeyAsync,
insertServerSettingByKeyAsync,
shouldEnableAutomaticMobileLayoutForUpgrade,
updateServerSettingByKeyAsync,
} from "../queries/server-setting";
@@ -156,33 +155,19 @@ const seedDefaultSearchEnginesAsync = async (db: Database) => {
console.log("Set Homarr docs as the default search engine");
};
export const seedServerSettingsAsync = async (db: Database) => {
const seedServerSettingsAsync = async (db: Database) => {
const serverSettingsData = await db.query.serverSettings.findMany();
const existingLayouts = await db.query.layouts.findMany({
columns: {
boardId: true,
name: true,
breakpoint: true,
},
});
const defaultsForThisInstance = {
...defaultServerSettings,
board: {
...defaultServerSettings.board,
enableAutomaticMobileLayout: shouldEnableAutomaticMobileLayoutForUpgrade(existingLayouts),
},
};
for (const settingsKey of defaultServerSettingsKeys) {
const currentDbEntry = serverSettingsData.find((setting) => setting.settingKey === settingsKey);
if (!currentDbEntry) {
await insertServerSettingByKeyAsync(db, settingsKey, defaultsForThisInstance[settingsKey]);
await insertServerSettingByKeyAsync(db, settingsKey, defaultServerSettings[settingsKey]);
console.log(`Created serverSetting through seed key=${settingsKey}`);
continue;
}
const currentSettings = SuperJSON.parse<(typeof defaultServerSettings)[typeof settingsKey]>(currentDbEntry.value);
const defaultSettings = defaultsForThisInstance[settingsKey];
const currentSettings = await getServerSettingByKeyAsync(db, settingsKey);
const defaultSettings = defaultServerSettings[settingsKey];
const missingKeys = objectKeys(defaultSettings).filter((key) => !(key in currentSettings));
if (missingKeys.length === 0) {
+10 -132
View File
@@ -1,80 +1,29 @@
import { parse, stringify } from "superjson";
import SuperJSON from "superjson";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
import { eq, sql } from "..";
import { isMysql, isPostgresql } from "../collection";
import type { HomarrDatabaseMysql, HomarrDatabasePostgresql } from "../driver";
import { eq } from "..";
import { serverSettings } from "../schema";
import { serverSettings as mysqlServerSettings } from "../schema/mysql";
import { serverSettings as postgresqlServerSettings } from "../schema/postgresql";
interface ExistingBoardLayout {
boardId: string;
name: string;
breakpoint: number;
}
export const shouldEnableAutomaticMobileLayoutForUpgrade = (existingLayouts: ExistingBoardLayout[]) => {
const layoutCounts = new Map<string, number>();
for (const layout of existingLayouts) {
if (layout.name !== "Base" || layout.breakpoint !== 0) {
return false;
}
const nextCount = (layoutCounts.get(layout.boardId) ?? 0) + 1;
if (nextCount > 1) {
return false;
}
layoutCounts.set(layout.boardId, nextCount);
}
return true;
};
const getAutomaticMobileLayoutUpgradeDefaultAsync = async (db: Database) => {
const existingLayouts = await db.query.layouts.findMany({
columns: {
boardId: true,
name: true,
breakpoint: true,
},
});
return shouldEnableAutomaticMobileLayoutForUpgrade(existingLayouts);
};
export const getServerSettingsAsync = async (db: Database) => {
const settings = await db.query.serverSettings.findMany();
let parsedBoardSettings: Partial<ServerSettings["board"]> = {};
const mergedSettings = defaultServerSettingsKeys.reduce((acc, settingKey) => {
const setting = settings.find((candidate) => candidate.settingKey === settingKey);
return defaultServerSettingsKeys.reduce((acc, settingKey) => {
const setting = settings.find((setting) => setting.settingKey === settingKey);
if (!setting) {
// Typescript is not happy because the key is a union and it does not know that they are the same
acc[settingKey] = { ...defaultServerSettings[settingKey] } as never;
acc[settingKey] = defaultServerSettings[settingKey] as never;
return acc;
}
const persistedSettings = parse<Record<string, unknown>>(setting.value);
if (settingKey === "board") {
parsedBoardSettings = persistedSettings as Partial<ServerSettings["board"]>;
}
acc[settingKey] = {
...defaultServerSettings[settingKey],
...persistedSettings,
} as never;
...SuperJSON.parse(setting.value),
};
return acc;
}, {} as ServerSettings);
if (typeof parsedBoardSettings.enableAutomaticMobileLayout !== "boolean") {
mergedSettings.board.enableAutomaticMobileLayout = await getAutomaticMobileLayoutUpgradeDefaultAsync(db);
}
return mergedSettings;
};
export const getServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(db: Database, key: TKey) => {
@@ -83,31 +32,10 @@ export const getServerSettingByKeyAsync = async <TKey extends keyof ServerSettin
});
if (!dbSettings) {
if (key === "board") {
return {
...defaultServerSettings.board,
enableAutomaticMobileLayout: await getAutomaticMobileLayoutUpgradeDefaultAsync(db),
} as ServerSettings[TKey];
}
return defaultServerSettings[key];
}
const persistedSettings = parse<Partial<ServerSettings[TKey]>>(dbSettings.value);
const mergedSettings = {
...defaultServerSettings[key],
...persistedSettings,
} as ServerSettings[TKey];
if (
key === "board" &&
typeof (persistedSettings as Partial<ServerSettings["board"]>).enableAutomaticMobileLayout !== "boolean"
) {
(mergedSettings as ServerSettings["board"]).enableAutomaticMobileLayout =
await getAutomaticMobileLayoutUpgradeDefaultAsync(db);
}
return mergedSettings;
return SuperJSON.parse<ServerSettings[TKey]>(dbSettings.value);
};
export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
@@ -118,7 +46,7 @@ export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSet
await db
.update(serverSettings)
.set({
value: stringify(value),
value: SuperJSON.stringify(value),
})
.where(eq(serverSettings.settingKey, key));
};
@@ -130,56 +58,6 @@ export const insertServerSettingByKeyAsync = async <TKey extends keyof ServerSet
) => {
await db.insert(serverSettings).values({
settingKey: key,
value: stringify(value),
value: SuperJSON.stringify(value),
});
};
export const mergeServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
db: Database,
key: TKey,
value: ServerSettings[TKey],
patch: Partial<ServerSettings[TKey]>,
) => {
const serializedValue = stringify(value);
const serializedPatch = stringify(patch);
if (isMysql()) {
await (db as unknown as HomarrDatabaseMysql)
.insert(mysqlServerSettings)
.values({ settingKey: key, value: serializedValue })
.onDuplicateKeyUpdate({
set: {
value: sql`JSON_MERGE_PATCH(${mysqlServerSettings.value}, ${serializedPatch})`,
},
});
return;
}
if (isPostgresql()) {
await (db as unknown as HomarrDatabasePostgresql)
.insert(postgresqlServerSettings)
.values({ settingKey: key, value: serializedValue })
.onConflictDoUpdate({
target: postgresqlServerSettings.settingKey,
set: {
value: sql`jsonb_set(
${postgresqlServerSettings.value}::jsonb,
'{json}',
(${postgresqlServerSettings.value}::jsonb -> 'json') || (${serializedPatch}::jsonb -> 'json'),
true
)::text`,
},
});
return;
}
await db
.insert(serverSettings)
.values({ settingKey: key, value: serializedValue })
.onConflictDoUpdate({
target: serverSettings.settingKey,
set: {
value: sql`json_patch(${serverSettings.value}, ${serializedPatch})`,
},
});
};
-1
View File
@@ -27,7 +27,6 @@ export const defaultServerSettings = {
board: {
homeBoardId: null as string | null,
mobileHomeBoardId: null as string | null,
enableAutomaticMobileLayout: true,
enableStatusByDefault: true,
forceDisableStatus: false,
},
+2 -3
View File
@@ -12,14 +12,14 @@ export type SettingsContextProps = Pick<
| "pingIconsEnabled"
| "enableRightClickOnWidgets"
> &
Pick<ServerSettings["board"], "enableAutomaticMobileLayout" | "enableStatusByDefault" | "forceDisableStatus"> &
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
Pick<ServerSettings["user"], "enableGravatar">;
export interface PublicServerSettings {
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
board: Pick<
ServerSettings["board"],
"homeBoardId" | "mobileHomeBoardId" | "enableAutomaticMobileLayout" | "enableStatusByDefault" | "forceDisableStatus"
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
>;
user: Pick<ServerSettings["user"], "enableGravatar">;
}
@@ -51,7 +51,6 @@ export const createSettings = ({
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
pingIconsEnabled: user?.pingIconsEnabled ?? false,
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
enableAutomaticMobileLayout: serverSettings.board.enableAutomaticMobileLayout,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
enableGravatar: serverSettings.user.enableGravatar,
-6
View File
@@ -5011,12 +5011,6 @@
}
},
"board": {
"mobilePreview": {
"title": "Mobile preview",
"action": "Mobile preview",
"collapse": "Collapse mobile preview",
"empty": "Add items to the board to preview the automatic mobile layout."
},
"action": {
"addContent": "Add board content",
"settings": "Board settings",
-3
View File
@@ -1574,9 +1574,6 @@ importers:
'@homarr/api':
specifier: workspace:^0.1.0
version: link:../api
'@homarr/settings':
specifier: workspace:^0.1.0
version: link:../settings
react:
specifier: 'catalog:'
version: 19.2.7