feat: add mobile layout preview

This commit is contained in:
ajnart committed 2026-08-14 11:32:09 +02:00
1 parent c805d5c631
commit bb5df3a2e6
9 files changed
+369 -29

No files matched your search

@@ -1,8 +1,9 @@
.grid {
container-type: inline-size;
display: grid;
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
grid-auto-flow: row;
grid-auto-rows: min(calc((100vw - 42px) / 2), 12rem);
grid-auto-rows: min(calc((100cqw - 42px) / var(--mobile-column-count)), 12rem);
gap: 10px;
padding-bottom: 10px;
}
@@ -5,7 +5,7 @@ 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, getMobileRootSection } from "./mobile-layout";
import { createMobileBoardItems, createMobileBoardPreviewItems, getMobileRootSection } from "./mobile-layout";
describe("createMobileBoardItems", () => {
test("selects the first root section in visual order", () => {
@@ -115,3 +115,37 @@ describe("createMobileBoardItems", () => {
]);
});
});
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, 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: "clock" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 1, height: 1, yOffset: 0 })
.build(),
);
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: "clock", width: 1, height: 1, title: null },
{ id: "wide", kind: "notebook", width: 2, height: 3, title: "Notes" },
]);
});
});
@@ -115,3 +115,12 @@ export const createMobileBoardItems = (board: Board, desktopLayoutId: string): S
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,
width: item.width,
height: item.height,
title: item.advancedOptions.title,
}));
@@ -0,0 +1,39 @@
.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;
}
@media (prefers-reduced-motion: reduce) {
.panel,
.item {
scroll-behavior: auto;
transition: none;
}
}
@@ -0,0 +1,115 @@
"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 { 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 { 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)", right: "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 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 label = 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">
<WidgetIcon size={14} aria-hidden />
</ThemeIcon>
<Text size="xs" fw={500} truncate title={label}>
{label}
</Text>
</Group>
</div>
);
})}
</div>
)}
</ScrollArea.Autosize>
);
};
+107 -26
View File
@@ -1,7 +1,6 @@
import type { Page } from "@playwright/test";
import { chromium, devices, expect } from "@playwright/test";
import { createId } from "@paralleldrive/cuid2";
import { stringify } from "superjson";
import { parse, stringify } from "superjson";
import { describe, test } from "vitest";
import { eq } from "drizzle-orm";
@@ -19,23 +18,12 @@ const credentials = {
const boardName = "mobile-e2e";
const setAutomaticMobileLayoutAsync = async (page: Page, enabled: boolean) => {
await page.goto(new URL("/manage/settings", page.url()).toString());
const toggle = page.getByRole("switch", { name: "Use automatic mobile layout" });
await expect(toggle).toBeVisible();
if ((await toggle.isChecked()) !== enabled) {
await toggle.setChecked(enabled);
await toggle.locator("xpath=ancestor::form").getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText("Settings saved successfully")).toBeVisible();
}
};
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 clockId = createId();
@@ -60,14 +48,23 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => {
breakpoint: 0,
},
]);
await db.insert(sqliteSchema.sections).values({
id: sectionId,
boardId,
kind: "category",
name: "Dashboard",
xOffset: 0,
yOffset: 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.items).values([
{
id: notebookId,
@@ -133,10 +130,42 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => {
};
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);
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: {
@@ -157,7 +186,26 @@ describe("Automatic mobile board", () => {
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 setAutomaticMobileLayoutAsync(loginPage, true);
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();
await previewToggle.click();
await expect(loginPage.locator("[data-mobile-board-preview]")).toBeVisible();
await expect(loginPage.locator("[data-mobile-board-preview-item]")).toHaveCount(2);
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({
@@ -173,6 +221,7 @@ describe("Automatic mobile board", () => {
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();
@@ -204,11 +253,43 @@ describe("Automatic mobile board", () => {
expect(metrics.firstToSecondHeightRatio).toBeGreaterThan(1.8);
expect(metrics.horizontalOverflow).toBeLessThanOrEqual(0);
await mobileContext.close();
} finally {
await loginContext.close();
await browser.close();
await container.stop();
}
}, 180_000);
await setAutomaticMobileLayoutAsync(loginPage, false);
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 loginContext.storageState(),
storageState: await desktopContext.storageState(),
viewport: { width: 390, height: 844 },
userAgent: devices["iPhone 13"].userAgent,
hasTouch: true,
@@ -221,7 +302,7 @@ describe("Automatic mobile board", () => {
await expect(legacyPage.getByRole("button", { name: "Toggle board edit mode", exact: true })).toBeVisible();
await legacyContext.close();
} finally {
await loginContext.close();
await desktopContext.close();
await browser.close();
await container.stop();
}
+1 -1
View File
@@ -55,7 +55,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 or be null",
"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",
},
})
.input(boardServerSettingsUpdateSchema)
+55
View File
@@ -72,6 +72,61 @@ describe("automatic mobile layout upgrade default", () => {
});
});
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();
+6
View File
@@ -5011,6 +5011,12 @@
}
},
"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",