diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index ea67e1ca5..35e9f49e4 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, 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. diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index fa94b0e69..7f4903143 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -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, }, 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 2f3875627..fd91c98d2 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 + 1 - size.width; xOffset++) { + for (let xOffset = 0; xOffset < columnCount; 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 8b8f3a02f..0348b3f06 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,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, 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 6b7f8d314..98d093826 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,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, 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 a4ed32931..848ea9ac2 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,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 & { 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 d8c10cdd5..de3e5804b 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,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); 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 5f930003a..41e838ed2 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,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); diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.module.css b/apps/nextjs/src/components/board/mobile/mobile-board.module.css deleted file mode 100644 index 4a243dd04..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-board.module.css +++ /dev/null @@ -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%; -} diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.tsx b/apps/nextjs/src/components/board/mobile/mobile-board.tsx deleted file mode 100644 index 20e4b9445..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-board.tsx +++ /dev/null @@ -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(null); - const itemRefs = useRef>>({}); - const gridstackRef = useRef(null); - - return ( - - - - - {items.map((item) => ( - - - - ))} - - - - - ); -}; diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts deleted file mode 100644 index 8569acc16..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts +++ /dev/null @@ -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" }, - ]); - }); -}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.ts deleted file mode 100644 index fc3e1079d..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.ts +++ /dev/null @@ -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 = ( - layouts: TLayout[], - desktopLayoutId: string, - layoutPriority: Map, -) => - 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(); - 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(); - const visitedSections = new Set(); - - 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, - })); diff --git a/apps/nextjs/src/components/board/mobile/mobile-preview.module.css b/apps/nextjs/src/components/board/mobile/mobile-preview.module.css deleted file mode 100644 index c7ee974d5..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-preview.module.css +++ /dev/null @@ -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; - } -} diff --git a/apps/nextjs/src/components/board/mobile/mobile-preview.tsx b/apps/nextjs/src/components/board/mobile/mobile-preview.tsx deleted file mode 100644 index 76c5bcb78..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-preview.tsx +++ /dev/null @@ -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 ( - - {isExpanded ? ( - - - - - {t("board.mobilePreview.title")} - - setIsExpanded(false)} - aria-label={t("board.mobilePreview.collapse")} - > - - - - - - ) : ( - - )} - - ); -}; - -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 ( - - {items.length === 0 ? ( -
- - - - {t("board.mobilePreview.empty")} - - -
- ) : ( -
- {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 ( -
- - - {app ? ( - - ) : ( - - )} - - - {label} - - -
- ); - })} -
- )} -
- ); -}; diff --git a/apps/nextjs/src/components/board/sections/section-actions.tsx b/apps/nextjs/src/components/board/sections/section-actions.tsx index 1d1a86def..a978e712f 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,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, diff --git a/apps/nextjs/src/components/board/use-mobile-board.tsx b/apps/nextjs/src/components/board/use-mobile-board.tsx deleted file mode 100644 index 8d35a927e..000000000 --- a/apps/nextjs/src/components/board/use-mobile-board.tsx +++ /dev/null @@ -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(undefined); - -export const MobileBoardViewportProvider = ({ - initialIsMobile, - children, -}: PropsWithChildren<{ initialIsMobile: boolean }>) => ( - {children} -); - -export const useIsMobileBoard = () => { - const initialIsMobile = useContext(InitialMobileBoardContext); - 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 deleted file mode 100644 index a0c850449..000000000 --- a/e2e/mobile-board.spec.ts +++ /dev/null @@ -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: "

Normal notebook content

" }), - 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("[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(); - - 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); -}); diff --git a/packages/api/src/router/serverSettings.ts b/packages/api/src/router/serverSettings.ts index 5456c7a22..9783e0ae9 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, - 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; @@ -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") diff --git a/packages/api/src/router/test/serverSettings.spec.ts b/packages/api/src/router/test/serverSettings.spec.ts index df1e917cf..c045b1288 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, 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({ diff --git a/packages/boards/package.json b/packages/boards/package.json index 90e485a83..057ac0550 100644 --- a/packages/boards/package.json +++ b/packages/boards/package.json @@ -25,7 +25,6 @@ }, "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 deleted file mode 100644 index 64a64732e..000000000 --- a/packages/boards/src/context.spec.ts +++ /dev/null @@ -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"]); - }); -}); diff --git a/packages/boards/src/edit-mode.tsx b/packages/boards/src/edit-mode.tsx index 6ca606b99..9b813b032 100644 --- a/packages/boards/src/edit-mode.tsx +++ b/packages/boards/src/edit-mode.tsx @@ -6,21 +6,12 @@ import { useDisclosure } from "@mantine/hooks"; const EditModeContext = createContext | 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 {children}; }; -export const ReadOnlyEditModeProvider = ({ children }: PropsWithChildren) => ( - {children} -); - export const useEditMode = () => { const context = useContext(EditModeContext); diff --git a/packages/db/collection.ts b/packages/db/collection.ts index 9839dbf06..b70418759 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 { schema } from "./schema"; +import * as 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 deleted file mode 100644 index 0e0c49acc..000000000 --- a/packages/db/migrations/seed.spec.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index 838cdfdc2..412c09e1e 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -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) { diff --git a/packages/db/queries/server-setting.ts b/packages/db/queries/server-setting.ts index 18f967582..371b18f29 100644 --- a/packages/db/queries/server-setting.ts +++ b/packages/db/queries/server-setting.ts @@ -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(); - - 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 = {}; - 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>(setting.value); - if (settingKey === "board") { - parsedBoardSettings = persistedSettings as Partial; - } 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 (db: Database, key: TKey) => { @@ -83,31 +32,10 @@ export const getServerSettingByKeyAsync = async >(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; + return SuperJSON.parse(dbSettings.value); }; export const updateServerSettingByKeyAsync = async ( @@ -118,7 +46,7 @@ export const updateServerSettingByKeyAsync = async { await db.insert(serverSettings).values({ settingKey: key, - value: stringify(value), + value: SuperJSON.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 4e94389fd..a8a52ea99 100644 --- a/packages/server-settings/src/index.ts +++ b/packages/server-settings/src/index.ts @@ -27,7 +27,6 @@ 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 1333ff220..ee7dfaf6e 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" | "enableAutomaticMobileLayout" | "enableStatusByDefault" | "forceDisableStatus" + "homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus" >; user: Pick; } @@ -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, diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index d2ac1d9b8..bddeeca2c 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f0622c34..06968df00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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