From 747234c040159881e68de796f304ee355c71f845 Mon Sep 17 00:00:00 2001 From: ajnart Date: Wed, 29 Jul 2026 20:21:59 +0200 Subject: [PATCH] fix: keep mobile board widgets unchanged --- apps/docs/docs/management/api/index.mdx | 2 +- apps/nextjs/src/app/[locale]/layout.tsx | 1 + .../board/items/actions/empty-position.ts | 2 +- .../items/actions/move-and-resize-item.ts | 9 +- .../items/actions/move-item-to-section.ts | 9 +- .../items/actions/test/empty-position.spec.ts | 4 + .../actions/test/move-and-resize-item.spec.ts | 12 +- .../actions/test/move-item-to-section.spec.ts | 12 +- .../board/mobile/mobile-board.module.css | 1 - .../board/mobile/mobile-layout.spec.ts | 21 ++ .../board/sections/section-actions.tsx | 15 +- .../src/components/board/use-mobile-board.tsx | 7 +- e2e/mobile-board.spec.ts | 229 ++++++++++++++++++ packages/api/src/router/serverSettings.ts | 26 +- .../src/router/test/serverSettings.spec.ts | 107 +++++++- packages/boards/package.json | 1 + packages/boards/src/context.spec.ts | 47 ++++ packages/db/collection.ts | 2 +- packages/db/migrations/seed.spec.ts | 115 +++++++++ packages/db/migrations/seed.ts | 23 +- packages/db/queries/server-setting.ts | 142 ++++++++++- packages/server-settings/src/index.ts | 1 + packages/settings/src/creator.ts | 5 +- pnpm-lock.yaml | 3 + 24 files changed, 719 insertions(+), 77 deletions(-) create mode 100644 e2e/mobile-board.spec.ts create mode 100644 packages/boards/src/context.spec.ts create mode 100644 packages/db/migrations/seed.spec.ts diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index 35e9f49e4..ea67e1ca5 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -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 and status behavior +- Configure global board defaults such as public home boards, automatic mobile layout mode, 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. diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 7f4903143..fa94b0e69 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -129,6 +129,7 @@ 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, }, diff --git a/apps/nextjs/src/components/board/items/actions/empty-position.ts b/apps/nextjs/src/components/board/items/actions/empty-position.ts index fd91c98d2..2f3875627 100644 --- a/apps/nextjs/src/components/board/items/actions/empty-position.ts +++ b/apps/nextjs/src/components/board/items/actions/empty-position.ts @@ -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; xOffset++) { + for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) { const isOccupied = elements.some( (element) => element.yOffset < yOffset + size.height && diff --git a/apps/nextjs/src/components/board/items/actions/move-and-resize-item.ts b/apps/nextjs/src/components/board/items/actions/move-and-resize-item.ts index 0348b3f06..8b8f3a02f 100644 --- a/apps/nextjs/src/components/board/items/actions/move-and-resize-item.ts +++ b/apps/nextjs/src/components/board/items/actions/move-and-resize-item.ts @@ -1,9 +1,8 @@ -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; @@ -11,10 +10,8 @@ export interface MoveAndResizeItemInput { } export const moveAndResizeItemCallback = - ({ itemId, ...layoutInput }: MoveAndResizeItemInput) => + ({ itemId, layoutId, ...layoutInput }: MoveAndResizeItemInput) => (previous: Board): Board => { - const currentLayout = getCurrentLayout(previous); - return { ...previous, items: previous.items.map((item) => @@ -23,7 +20,7 @@ export const moveAndResizeItemCallback = : { ...item, layouts: item.layouts.map((layout) => - layout.layoutId !== currentLayout + layout.layoutId !== layoutId ? layout : { ...layout, diff --git a/apps/nextjs/src/components/board/items/actions/move-item-to-section.ts b/apps/nextjs/src/components/board/items/actions/move-item-to-section.ts index 98d093826..6b7f8d314 100644 --- a/apps/nextjs/src/components/board/items/actions/move-item-to-section.ts +++ b/apps/nextjs/src/components/board/items/actions/move-item-to-section.ts @@ -1,9 +1,8 @@ -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; @@ -12,10 +11,8 @@ export interface MoveItemToSectionInput { } export const moveItemToSectionCallback = - ({ itemId, ...layoutInput }: MoveItemToSectionInput) => + ({ itemId, layoutId, ...layoutInput }: MoveItemToSectionInput) => (board: Board): Board => { - const currentLayout = getCurrentLayout(board); - return { ...board, items: board.items.map((item) => @@ -24,7 +21,7 @@ export const moveItemToSectionCallback = : { ...item, layouts: item.layouts.map((layout) => - layout.layoutId !== currentLayout + layout.layoutId !== layoutId ? layout : { ...layout, diff --git a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts index 848ea9ac2..a4ed32931 100644 --- a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts @@ -106,6 +106,10 @@ 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 & { char: string })[] = []; diff --git a/apps/nextjs/src/components/board/items/actions/test/move-and-resize-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/move-and-resize-item.spec.ts index de3e5804b..d8c10cdd5 100644 --- a/apps/nextjs/src/components/board/items/actions/test/move-and-resize-item.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/move-and-resize-item.spec.ts @@ -1,6 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; - -import * as boardContext from "@homarr/boards/context"; +import { describe, expect, test } from "vitest"; import { moveAndResizeItemCallback } from "../move-and-resize-item"; import { BoardMockBuilder } from "./mocks/board-mock"; @@ -11,8 +9,6 @@ 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, @@ -25,7 +21,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, ...newPosition })(board); + const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board); // Assert expect(updatedBoard.items).toHaveLength(3); @@ -43,8 +39,6 @@ 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, @@ -56,7 +50,7 @@ describe("moveItemToSectionCallback should move item in section", () => { const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build(); // Act - const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board); + const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board); // Assert expect(updatedBoard.items).toHaveLength(2); diff --git a/apps/nextjs/src/components/board/items/actions/test/move-item-to-section.spec.ts b/apps/nextjs/src/components/board/items/actions/test/move-item-to-section.spec.ts index 41e838ed2..5f930003a 100644 --- a/apps/nextjs/src/components/board/items/actions/test/move-item-to-section.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/move-item-to-section.spec.ts @@ -1,6 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; - -import * as boardContext from "@homarr/boards/context"; +import { describe, expect, test } from "vitest"; import { moveItemToSectionCallback } from "../move-item-to-section"; import { BoardMockBuilder } from "./mocks/board-mock"; @@ -11,8 +9,6 @@ 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, @@ -26,7 +22,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, ...newPosition })(board); + const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board); // Assert expect(updatedBoard.items).toHaveLength(3); @@ -44,8 +40,6 @@ 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, @@ -58,7 +52,7 @@ describe("moveItemToSectionCallback should move item to section", () => { const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build(); // Act - const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board); + const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board); // Assert expect(updatedBoard.items).toHaveLength(2); diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.module.css b/apps/nextjs/src/components/board/mobile/mobile-board.module.css index 902e77372..282bde4fe 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.module.css +++ b/apps/nextjs/src/components/board/mobile/mobile-board.module.css @@ -16,5 +16,4 @@ .item > :global(.grid-stack-item-content) { width: 100%; height: 100%; - overflow-y: auto; } diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts index 3e06594d0..e6c415682 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts @@ -93,4 +93,25 @@ describe("createMobileBoardItems", () => { 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 }, + ]); + }); }); diff --git a/apps/nextjs/src/components/board/sections/section-actions.tsx b/apps/nextjs/src/components/board/sections/section-actions.tsx index a978e712f..1d1a86def 100644 --- a/apps/nextjs/src/components/board/sections/section-actions.tsx +++ b/apps/nextjs/src/components/board/sections/section-actions.tsx @@ -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,6 +12,7 @@ interface MoveAndResizeInnerSection { } interface MoveInnerSectionToSection { innerSectionId: string; + layoutId: string; sectionId: string; xOffset: number; yOffset: number; @@ -23,7 +24,7 @@ export const useSectionActions = () => { const { updateBoard } = useUpdateBoard(); const moveAndResizeInnerSection = useCallback( - ({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => { + ({ innerSectionId, layoutId, ...positionProps }: MoveAndResizeInnerSection) => { updateBoard((previous) => ({ ...previous, sections: previous.sections.map((section) => { @@ -31,12 +32,10 @@ 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 !== currentLayout) return layout; + if (layout.layoutId !== layoutId) return layout; return { ...layout, ...positionProps, @@ -50,7 +49,7 @@ export const useSectionActions = () => { ); const moveInnerSectionToSection = useCallback( - ({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => { + ({ innerSectionId, layoutId, sectionId, ...positionProps }: MoveInnerSectionToSection) => { updateBoard((previous) => { return { ...previous, @@ -59,12 +58,10 @@ 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 !== currentLayout) return layout; + if (layout.layoutId !== layoutId) return layout; return { ...layout, ...positionProps, diff --git a/apps/nextjs/src/components/board/use-mobile-board.tsx b/apps/nextjs/src/components/board/use-mobile-board.tsx index 84eea8508..8d35a927e 100644 --- a/apps/nextjs/src/components/board/use-mobile-board.tsx +++ b/apps/nextjs/src/components/board/use-mobile-board.tsx @@ -4,6 +4,8 @@ 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(undefined); @@ -17,5 +19,8 @@ export const MobileBoardViewportProvider = ({ export const useIsMobileBoard = () => { const initialIsMobile = useContext(InitialMobileBoardContext); - return useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true }); + const { enableAutomaticMobileLayout } = useSettings(); + const isMobileViewport = useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true }); + + return enableAutomaticMobileLayout && isMobileViewport; }; diff --git a/e2e/mobile-board.spec.ts b/e2e/mobile-board.spec.ts new file mode 100644 index 000000000..ea7b8cddf --- /dev/null +++ b/e2e/mobile-board.spec.ts @@ -0,0 +1,229 @@ +import type { Page } from "@playwright/test"; +import { chromium, devices, expect } from "@playwright/test"; +import { createId } from "@paralleldrive/cuid2"; +import { 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 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 notebookId = createId(); + const clockId = 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, + }); + await db.insert(sqliteSchema.items).values([ + { + id: notebookId, + boardId, + kind: "notebook", + options: stringify({ content: "

Normal notebook content

" }), + advancedOptions: stringify({}), + }, + { + id: clockId, + boardId, + kind: "clock", + options: stringify({}), + advancedOptions: stringify({}), + }, + ]); + await db.insert(sqliteSchema.itemLayouts).values([ + { + itemId: notebookId, + layoutId: desktopLayoutId, + sectionId, + xOffset: 0, + yOffset: 0, + width: 2, + height: 2, + }, + { + itemId: clockId, + 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: clockId, + 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)); +}; + +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 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 setAutomaticMobileLayoutAsync(loginPage, true); + 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(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: "Edit item", 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("[data-mobile-board]"); + const itemElements = Array.from(document.querySelectorAll("[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(); + + await setAutomaticMobileLayoutAsync(loginPage, false); + + const legacyContext = await browser.newContext({ + storageState: await loginContext.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: "Edit item", exact: true })).toBeVisible(); + await legacyContext.close(); + } finally { + await loginContext.close(); + await browser.close(); + await container.stop(); + } + }, 180_000); +}); diff --git a/packages/api/src/router/serverSettings.ts b/packages/api/src/router/serverSettings.ts index 9783e0ae9..2a603c757 100644 --- a/packages/api/src/router/serverSettings.ts +++ b/packages/api/src/router/serverSettings.ts @@ -5,10 +5,10 @@ import { and, eq, inArray } from "@homarr/db"; import { getServerSettingByKeyAsync, getServerSettingsAsync, - insertServerSettingByKeyAsync, + mergeServerSettingByKeyAsync, updateServerSettingByKeyAsync, } from "@homarr/db/queries"; -import { boards, serverSettings } from "@homarr/db/schema"; +import { boards } from "@homarr/db/schema"; import type { ServerSettings } from "@homarr/server-settings"; import { defaultServerSettingsKeys } from "@homarr/server-settings"; import { settingsInitSchema } from "@homarr/validation/settings"; @@ -19,6 +19,7 @@ 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; @@ -39,7 +40,7 @@ export const serverSettingsRouter = createTRPCRouter({ mcp: { enabled: true, description: - "Get global board defaults, including desktop/mobile home board IDs and status behavior. Requires admin permission", + "Get global board defaults, including desktop/mobile home board IDs, automatic mobile layout mode, and status behavior. Requires admin permission", }, }) .input(z.void()) @@ -54,7 +55,7 @@ export const serverSettingsRouter = createTRPCRouter({ mcp: { enabled: true, description: - "Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, 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 or be null", }, }) .input(boardServerSettingsUpdateSchema) @@ -81,17 +82,9 @@ export const serverSettingsRouter = createTRPCRouter({ const current = await getServerSettingByKeyAsync(ctx.db, "board"); const next = { ...current, ...input }; - const existing = await ctx.db.query.serverSettings.findFirst({ - where: eq(serverSettings.settingKey, "board"), - }); + await mergeServerSettingByKeyAsync(ctx.db, "board", next, input); - if (existing) { - await updateServerSettingByKeyAsync(ctx.db, "board", next); - } else { - await insertServerSettingByKeyAsync(ctx.db, "board", next); - } - - return next; + return await getServerSettingByKeyAsync(ctx.db, "board"); }), saveSettings: permissionRequiredProcedure .requiresPermission("admin") @@ -103,10 +96,11 @@ export const serverSettingsRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey); - await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, { + const next = { ...current, ...input.value, - } as ServerSettings[keyof ServerSettings]); + } as ServerSettings[keyof ServerSettings]; + await mergeServerSettingByKeyAsync(ctx.db, input.settingsKey, next, input.value); }), initSettings: onboardingProcedure .requiresStep("settings") diff --git a/packages/api/src/router/test/serverSettings.spec.ts b/packages/api/src/router/test/serverSettings.spec.ts index c045b1288..df1e917cf 100644 --- a/packages/api/src/router/test/serverSettings.spec.ts +++ b/packages/api/src/router/test/serverSettings.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; import { createId } from "@homarr/common"; -import { boards, serverSettings } from "@homarr/db/schema"; +import { boards, layouts, serverSettings } from "@homarr/db/schema"; import { createDb } from "@homarr/db/test"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; @@ -56,6 +56,33 @@ 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({ @@ -89,6 +116,33 @@ 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", () => { @@ -103,6 +157,36 @@ 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({ @@ -145,6 +229,7 @@ describe("board settings API", () => { await caller.updateBoardSettings({ homeBoardId: boardId }); const result = await caller.updateBoardSettings({ mobileHomeBoardId: boardId, + enableAutomaticMobileLayout: false, enableStatusByDefault: false, }); @@ -152,6 +237,7 @@ describe("board settings API", () => { ...defaultServerSettings.board, homeBoardId: boardId, mobileHomeBoardId: boardId, + enableAutomaticMobileLayout: false, enableStatusByDefault: false, }); const dbSettings = await db.select().from(serverSettings); @@ -163,6 +249,25 @@ 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({ diff --git a/packages/boards/package.json b/packages/boards/package.json index 057ac0550..90e485a83 100644 --- a/packages/boards/package.json +++ b/packages/boards/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@homarr/api": "workspace:^0.1.0", + "@homarr/settings": "workspace:^0.1.0", "react": "catalog:", "react-dom": "catalog:" }, diff --git a/packages/boards/src/context.spec.ts b/packages/boards/src/context.spec.ts new file mode 100644 index 000000000..64a64732e --- /dev/null +++ b/packages/boards/src/context.spec.ts @@ -0,0 +1,47 @@ +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"]); + }); +}); diff --git a/packages/db/collection.ts b/packages/db/collection.ts index b70418759..9839dbf06 100644 --- a/packages/db/collection.ts +++ b/packages/db/collection.ts @@ -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 * as schema from "./schema"; +import { schema } from "./schema"; type TableKey = { [K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never; diff --git a/packages/db/migrations/seed.spec.ts b/packages/db/migrations/seed.spec.ts new file mode 100644 index 000000000..5346026c0 --- /dev/null +++ b/packages/db/migrations/seed.spec.ts @@ -0,0 +1,115 @@ +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", (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("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); + }); +}); diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index 412c09e1e..838cdfdc2 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -21,6 +21,7 @@ import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries"; import { getServerSettingByKeyAsync, insertServerSettingByKeyAsync, + shouldEnableAutomaticMobileLayoutForUpgrade, updateServerSettingByKeyAsync, } from "../queries/server-setting"; @@ -155,19 +156,33 @@ const seedDefaultSearchEnginesAsync = async (db: Database) => { console.log("Set Homarr docs as the default search engine"); }; -const seedServerSettingsAsync = async (db: Database) => { +export 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, defaultServerSettings[settingsKey]); + await insertServerSettingByKeyAsync(db, settingsKey, defaultsForThisInstance[settingsKey]); console.log(`Created serverSetting through seed key=${settingsKey}`); continue; } - const currentSettings = await getServerSettingByKeyAsync(db, settingsKey); - const defaultSettings = defaultServerSettings[settingsKey]; + const currentSettings = SuperJSON.parse<(typeof defaultServerSettings)[typeof settingsKey]>(currentDbEntry.value); + const defaultSettings = defaultsForThisInstance[settingsKey]; const missingKeys = objectKeys(defaultSettings).filter((key) => !(key in currentSettings)); if (missingKeys.length === 0) { diff --git a/packages/db/queries/server-setting.ts b/packages/db/queries/server-setting.ts index 371b18f29..18f967582 100644 --- a/packages/db/queries/server-setting.ts +++ b/packages/db/queries/server-setting.ts @@ -1,29 +1,80 @@ -import SuperJSON from "superjson"; +import { parse, stringify } from "superjson"; import type { ServerSettings } from "@homarr/server-settings"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; import type { Database } from ".."; -import { eq } from ".."; +import { eq, sql } from ".."; +import { isMysql, isPostgresql } from "../collection"; +import type { HomarrDatabaseMysql, HomarrDatabasePostgresql } from "../driver"; 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(); + + 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 = {}; - return defaultServerSettingsKeys.reduce((acc, settingKey) => { - const setting = settings.find((setting) => setting.settingKey === settingKey); + const mergedSettings = defaultServerSettingsKeys.reduce((acc, settingKey) => { + const setting = settings.find((candidate) => candidate.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>(setting.value); + if (settingKey === "board") { + parsedBoardSettings = persistedSettings as Partial; + } acc[settingKey] = { ...defaultServerSettings[settingKey], - ...SuperJSON.parse(setting.value), - }; + ...persistedSettings, + } as never; return acc; }, {} as ServerSettings); + + if (typeof parsedBoardSettings.enableAutomaticMobileLayout !== "boolean") { + mergedSettings.board.enableAutomaticMobileLayout = await getAutomaticMobileLayoutUpgradeDefaultAsync(db); + } + + return mergedSettings; }; export const getServerSettingByKeyAsync = async (db: Database, key: TKey) => { @@ -32,10 +83,31 @@ export const getServerSettingByKeyAsync = async (dbSettings.value); + const persistedSettings = parse>(dbSettings.value); + const mergedSettings = { + ...defaultServerSettings[key], + ...persistedSettings, + } as ServerSettings[TKey]; + + if ( + key === "board" && + typeof (persistedSettings as Partial).enableAutomaticMobileLayout !== "boolean" + ) { + (mergedSettings as ServerSettings["board"]).enableAutomaticMobileLayout = + await getAutomaticMobileLayoutUpgradeDefaultAsync(db); + } + + return mergedSettings; }; export const updateServerSettingByKeyAsync = async ( @@ -46,7 +118,7 @@ export const updateServerSettingByKeyAsync = async { await db.insert(serverSettings).values({ settingKey: key, - value: SuperJSON.stringify(value), + value: stringify(value), }); }; + +export const mergeServerSettingByKeyAsync = async ( + db: Database, + key: TKey, + value: ServerSettings[TKey], + patch: Partial, +) => { + 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})`, + }, + }); +}; diff --git a/packages/server-settings/src/index.ts b/packages/server-settings/src/index.ts index a8a52ea99..4e94389fd 100644 --- a/packages/server-settings/src/index.ts +++ b/packages/server-settings/src/index.ts @@ -27,6 +27,7 @@ export const defaultServerSettings = { board: { homeBoardId: null as string | null, mobileHomeBoardId: null as string | null, + enableAutomaticMobileLayout: true, enableStatusByDefault: true, forceDisableStatus: false, }, diff --git a/packages/settings/src/creator.ts b/packages/settings/src/creator.ts index ee7dfaf6e..1333ff220 100644 --- a/packages/settings/src/creator.ts +++ b/packages/settings/src/creator.ts @@ -12,14 +12,14 @@ export type SettingsContextProps = Pick< | "pingIconsEnabled" | "enableRightClickOnWidgets" > & - Pick & + Pick & Pick; export interface PublicServerSettings { search: Pick; board: Pick< ServerSettings["board"], - "homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus" + "homeBoardId" | "mobileHomeBoardId" | "enableAutomaticMobileLayout" | "enableStatusByDefault" | "forceDisableStatus" >; user: Pick; } @@ -51,6 +51,7 @@ 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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06968df00..0f0622c34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1574,6 +1574,9 @@ 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