fix: refine mobile board preview

This commit is contained in:
ajnart committed 2026-08-13 19:55:34 +02:00
1 parent 866b3ab78b
commit dec04da806
5 files changed
+51 -15

No files matched your search

@@ -125,7 +125,7 @@ describe("createMobileBoardPreviewItems", () => {
expect(createMobileBoardPreviewItems(board, desktopLayoutId)).toEqual([]);
});
test("uses the production mobile order, footprint, kind, and custom title", () => {
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");
@@ -135,17 +135,20 @@ describe("createMobileBoardPreviewItems", () => {
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" })
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: "clock", width: 1, height: 1, title: null },
{ id: "wide", kind: "notebook", width: 2, height: 3, title: "Notes" },
{ 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" },
]);
});
});
@@ -120,6 +120,7 @@ export const createMobileBoardPreviewItems = (board: Board, desktopLayoutId: str
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,
@@ -30,6 +30,11 @@
min-width: 0;
}
.appIcon {
width: 14px;
height: 14px;
}
@media (prefers-reduced-motion: reduce) {
.panel,
.item {
@@ -5,10 +5,12 @@ 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";
@@ -31,7 +33,7 @@ export const MobileBoardPreview = () => {
if (!isEditMode || isMobileBoard || !enableAutomaticMobileLayout) return null;
return (
<Affix position={{ bottom: "var(--mantine-spacing-md)", right: "var(--mantine-spacing-md)" }} zIndex={200}>
<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">
@@ -67,6 +69,12 @@ 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 (
@@ -88,7 +96,8 @@ const MobileBoardPreviewMap = () => {
>
{items.map((item) => {
const WidgetIcon = widgetCatalogIcons[item.kind];
const label = item.title?.trim() || t(`widget.${item.kind}.name`);
const app = item.appId === null ? undefined : appsById.get(item.appId);
const label = app?.name ?? (item.title?.trim() || t(`widget.${item.kind}.name`));
return (
<div
@@ -98,8 +107,12 @@ const MobileBoardPreviewMap = () => {
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 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}
+21 -7
View File
@@ -25,7 +25,8 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => {
const sectionId = createId();
const emptySectionId = createId();
const notebookId = createId();
const clockId = createId();
const appItemId = createId();
const appId = createId();
await db.insert(sqliteSchema.boards).values({
id: boardId,
@@ -65,6 +66,11 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => {
yOffset: 1,
},
]);
await db.insert(sqliteSchema.apps).values({
id: appId,
name: "Media Server",
iconUrl: "/favicon.ico",
});
await db.insert(sqliteSchema.items).values([
{
id: notebookId,
@@ -74,10 +80,10 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => {
advancedOptions: stringify({}),
},
{
id: clockId,
id: appItemId,
boardId,
kind: "clock",
options: stringify({}),
kind: "app",
options: stringify({ appId }),
advancedOptions: stringify({}),
},
]);
@@ -92,7 +98,7 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => {
height: 2,
},
{
itemId: clockId,
itemId: appItemId,
layoutId: desktopLayoutId,
sectionId,
xOffset: 2,
@@ -110,7 +116,7 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => {
height: 4,
},
{
itemId: clockId,
itemId: appItemId,
layoutId: mobileLayoutId,
sectionId,
xOffset: 0,
@@ -127,6 +133,8 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => {
completedBoardTour: true,
})
.where(eq(sqliteSchema.users.id, userId));
return { appId };
};
describe("Automatic mobile board", () => {
@@ -155,7 +163,7 @@ describe("Automatic mobile board", () => {
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);
const { appId } = await seedBoardAsync(db, userId);
await db.insert(sqliteSchema.serverSettings).values({
settingKey: "board",
value: stringify({
@@ -191,9 +199,15 @@ describe("Automatic mobile board", () => {
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") })