From e6bd1adfc206d45bfe037488cf3a1feb2346f72b Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 28 Jul 2026 19:39:01 +0200 Subject: [PATCH] feat: complete automatic mobile board UX --- apps/docs/docs/management/api/index.mdx | 2 + .../boards/(content)/_prefetch-policy.spec.ts | 10 +- .../boards/(content)/_prefetch-policy.ts | 8 +- .../[locale]/boards/[name]/settings/page.tsx | 2 +- .../[locale]/boards/_board-switcher-logo.tsx | 13 +- apps/nextjs/src/app/[locale]/layout.tsx | 1 + .../items/actions/move-and-resize-item.ts | 9 +- .../items/actions/move-item-to-section.ts | 9 +- .../items/actions/test/create-item.spec.ts | 14 + .../actions/test/move-and-resize-item.spec.ts | 12 +- .../actions/test/move-item-to-section.spec.ts | 12 +- .../board/mobile/deferred-mobile-item.tsx | 64 ++-- .../components/board/mobile/mobile-board.tsx | 23 +- .../board/mobile/mobile-presentation.spec.ts | 44 ++- .../board/mobile/mobile-presentation.ts | 42 ++- .../mobile/mobile-viewport-observer.spec.ts | 103 ++++++ .../board/mobile/mobile-viewport-observer.ts | 116 +++++++ .../board/mobile/mobile-widget-actions.tsx | 302 +++++++++++------- .../board/sections/section-actions.tsx | 15 +- .../components/board/use-mobile-board.spec.ts | 29 +- .../src/components/board/use-mobile-board.tsx | 38 ++- .../language/current-language-combobox.tsx | 3 +- .../src/components/user-avatar-menu.tsx | 12 +- e2e/mobile-board.spec.ts | 153 +++++++-- packages/api/src/router/board.ts | 6 +- packages/api/src/router/serverSettings.ts | 18 +- packages/api/src/router/test/board.spec.ts | 67 +--- .../src/router/test/serverSettings.spec.ts | 50 +++ packages/api/src/settle-integrations.spec.ts | 18 +- packages/api/src/settle-integrations.ts | 13 + packages/api/src/test/mcp.spec.ts | 1 + packages/boards/package.json | 1 + packages/boards/src/context.spec.ts | 36 +++ packages/boards/src/edit-mode.spec.ts | 30 ++ packages/boards/src/edit-mode.tsx | 22 ++ packages/db/migrations/seed.spec.ts | 106 ++++++ packages/db/migrations/seed.ts | 23 +- packages/db/queries/server-setting.ts | 87 ++++- packages/modals/src/confirm-modal.tsx | 1 + packages/modals/src/index.tsx | 7 +- packages/server-settings/src/index.ts | 1 + packages/settings/src/creator.ts | 5 +- packages/spotlight/package.json | 1 + .../src/preferences/use-user-preference.ts | 3 +- packages/widgets/src/bookmarks/component.tsx | 6 +- packages/widgets/src/common/query-state.tsx | 73 +++++ packages/widgets/src/custom-api/index.ts | 2 +- .../widgets/src/dns-hole/controls/index.ts | 2 +- .../widgets/src/dns-hole/summary/index.ts | 1 + pnpm-lock.yaml | 6 + 50 files changed, 1279 insertions(+), 343 deletions(-) create mode 100644 apps/nextjs/src/components/board/mobile/mobile-viewport-observer.spec.ts create mode 100644 apps/nextjs/src/components/board/mobile/mobile-viewport-observer.ts create mode 100644 packages/boards/src/context.spec.ts create mode 100644 packages/boards/src/edit-mode.spec.ts create mode 100644 packages/db/migrations/seed.spec.ts create mode 100644 packages/widgets/src/common/query-state.tsx diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index 359d189e6..35e9f49e4 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -82,6 +82,8 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio - Set desktop and mobile home boards for the current user - 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. + Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags. ## Partial integration failures diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts index 448119508..f0a978547 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts @@ -4,14 +4,18 @@ import { shouldPrefetchWidgetForRequest } from "./_prefetch-policy"; describe("shouldPrefetchWidgetForRequest", () => { test.each(["mobile", "tablet"])("defers downloads for %s requests", (deviceType) => { - expect(shouldPrefetchWidgetForRequest("downloads", deviceType)).toBe(false); + expect(shouldPrefetchWidgetForRequest("downloads", deviceType, true)).toBe(false); }); test("keeps downloads prefetched for desktop requests", () => { - expect(shouldPrefetchWidgetForRequest("downloads", undefined)).toBe(true); + expect(shouldPrefetchWidgetForRequest("downloads", undefined, true)).toBe(true); + }); + + test("keeps downloads prefetched for legacy responsive mobile boards", () => { + expect(shouldPrefetchWidgetForRequest("downloads", "mobile", false)).toBe(true); }); test.each(["app", "bookmarks"] as const)("keeps eager %s data prefetched on mobile", (kind) => { - expect(shouldPrefetchWidgetForRequest(kind, "mobile")).toBe(true); + expect(shouldPrefetchWidgetForRequest(kind, "mobile", true)).toBe(true); }); }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts index c5a89a754..b442f7cf5 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts @@ -1,7 +1,11 @@ import type { WidgetKind } from "@homarr/definitions"; -export const shouldPrefetchWidgetForRequest = (kind: WidgetKind, deviceType: string | undefined) => { +export const shouldPrefetchWidgetForRequest = ( + kind: WidgetKind, + deviceType: string | undefined, + enableAutomaticMobileLayout: boolean, +) => { const isMobileDevice = deviceType === "mobile" || deviceType === "tablet"; - return kind !== "downloads" || !isMobileDevice; + return kind !== "downloads" || !isMobileDevice || !enableAutomaticMobileLayout; }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx index f6af46d65..734542221 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx @@ -53,7 +53,7 @@ export default async function BoardSettingsPage(props: Props) { } return ( - + {t("title", { boardName: capitalize(board.name) })} { {boards.map((availableBoard) => { const isCurrent = availableBoard.id === board.id; + if (isCurrent) { + return ( + } aria-current="page" disabled> + + {availableBoard.name} + + + ); + } + return ( : } - aria-current={isCurrent ? "page" : undefined} + leftSection={} > {availableBoard.name} 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/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/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts index ea57dce5e..7f97054ba 100644 --- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts @@ -14,6 +14,20 @@ describe("item actions create-item", () => { vi.restoreAllMocks(); }); + test("clips oversized widgets to each layout's column count", () => { + const board = new BoardMockBuilder() + .addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 }) + .addEmptySection({ id: "section" }) + .build(); + const baseLayout = board.layouts.find((layout) => layout.role === "base"); + + const result = createItemCallback({ kind: "mediaMissing" })(board); + + const createdItem = result.items.at(0); + expect(createdItem?.layouts.find((layout) => layout.layoutId === "mobile")?.width).toBe(3); + expect(createdItem?.layouts.find((layout) => layout.layoutId === baseLayout?.id)?.width).toBe(4); + }); + test("should add it to first section", () => { // Arrange const itemKind = "clock"; 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/deferred-mobile-item.tsx b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx index de0fa9d98..b66894c39 100644 --- a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx +++ b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx @@ -5,66 +5,56 @@ import { useEffect, useRef, useState } from "react"; import { Skeleton } from "@mantine/core"; import classes from "./mobile-board.module.css"; +import { observeMobileViewportProximity } from "./mobile-viewport-observer"; interface DeferredMobileItemProps extends PropsWithChildren { eager: boolean; unmountWhenOffscreen: boolean; + onNearViewportChange?: (isNearViewport: boolean) => void; } -export const mobilePreloadViewportMultiplier = 2; +export { getMobilePreloadRootMargin, mobilePreloadViewportMultiplier } from "./mobile-viewport-observer"; -export const getMobilePreloadRootMargin = (viewportHeight: number) => - `${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`; - -export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, children }: DeferredMobileItemProps) => { +export const DeferredMobileItem = ({ + eager, + unmountWhenOffscreen, + onNearViewportChange, + children, +}: DeferredMobileItemProps) => { const [hasMounted, setHasMounted] = useState(eager); const wrapperRef = useRef(null); - const hasMountedOnceRef = useRef(eager); useEffect(() => { if (eager) { - hasMountedOnceRef.current = true; setHasMounted(true); + onNearViewportChange?.(true); return; } const element = wrapperRef.current; - if (!element || typeof IntersectionObserver === "undefined") { + if (!element) { setHasMounted(true); + onNearViewportChange?.(true); return; } - let observer: IntersectionObserver | null = null; - const observe = () => { - if (hasMountedOnceRef.current && !unmountWhenOffscreen) return; - observer?.disconnect(); - observer = new IntersectionObserver( - ([entry]) => { - const isNearViewport = entry?.isIntersecting ?? false; + return observeMobileViewportProximity( + element, + (isNearViewport) => { + onNearViewportChange?.(isNearViewport); - if (isNearViewport) { - hasMountedOnceRef.current = true; - setHasMounted(true); - if (!unmountWhenOffscreen) observer?.disconnect(); - return; - } + if (isNearViewport) { + setHasMounted(true); + return; + } - if (unmountWhenOffscreen && !element.contains(document.activeElement)) { - setHasMounted(false); - } - }, - { rootMargin: getMobilePreloadRootMargin(window.innerHeight) }, - ); - observer.observe(element); - }; - - observe(); - window.addEventListener("resize", observe); - return () => { - window.removeEventListener("resize", observe); - observer?.disconnect(); - }; - }, [eager, unmountWhenOffscreen]); + if (unmountWhenOffscreen && !element.contains(document.activeElement)) { + setHasMounted(false); + } + }, + { once: !unmountWhenOffscreen }, + ); + }, [eager, onNearViewportChange, unmountWhenOffscreen]); return (
diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.tsx b/apps/nextjs/src/components/board/mobile/mobile-board.tsx index 6f67e977f..58940420f 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-board.tsx @@ -1,8 +1,9 @@ "use client"; import type { CSSProperties, RefObject } from "react"; -import { useMemo, useRef } from "react"; +import { useMemo, useRef, useState } from "react"; import { Box, Center, Stack, Text, ThemeIcon, Title } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; import { IconLayoutBoard } from "@tabler/icons-react"; import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context"; @@ -63,6 +64,8 @@ const getMobilePresentation = (item: SectionItem) => { const MobileBoardItem = ({ item }: { item: SectionItem }) => { const widgetStateRef = useRef | null>(null); const presentation = getMobilePresentation(item); + const [isNearViewport, setIsNearViewport] = useState(presentation.eager); + const [detailsOpened, detailsDisclosure] = useDisclosure(false); return ( { data-mobile-board-item={item.id} data-mobile-display-mode={presentation.displayMode} > - + - + ); }; diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts index a145bc20b..45e59a506 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest"; import { isMobileContextActionVisible, resolveMobileItemPresentation, + shouldKeepMobileWidgetActionsMounted, shouldRenderMobileWidgetActions, } from "./mobile-presentation"; @@ -42,7 +43,7 @@ describe("resolveMobileItemPresentation", () => { }); }); - test("keeps automatic footprint defaults when only details are configured", () => { + test("uses a compact one-row fallback when only details are configured", () => { expect( resolveMobileItemPresentation( { kind: "calendar", height: 7 }, @@ -52,8 +53,10 @@ describe("resolveMobileItemPresentation", () => { ), ).toMatchObject({ width: 2, - height: 3, + height: 1, + displayMode: "mobileSummary", supportsDetails: true, + usesGenericSummary: true, }); }); @@ -75,6 +78,7 @@ describe("resolveMobileItemPresentation", () => { height: 1, displayMode: "mobileSummary", supportsDetails: false, + usesGenericSummary: false, eager: true, unmountWhenOffscreen: false, }); @@ -101,6 +105,42 @@ describe("shouldRenderMobileWidgetActions", () => { }); }); +describe("shouldKeepMobileWidgetActionsMounted", () => { + test("does not expose actions before a deferred widget is near the viewport", () => { + expect( + shouldKeepMobileWidgetActionsMounted({ + isNearViewport: false, + actionsOpened: false, + detailsOpened: false, + isOpeningDetails: false, + isCompletingAction: false, + actionTriggerHasFocus: false, + }), + ).toBe(false); + }); + + test.each([ + ["near the viewport", { isNearViewport: true }], + ["showing its action drawer", { actionsOpened: true }], + ["showing its detail view", { detailsOpened: true }], + ["transitioning to details", { isOpeningDetails: true }], + ["completing an action", { isCompletingAction: true }], + ["retaining keyboard focus", { actionTriggerHasFocus: true }], + ])("keeps actions mounted while %s", (_label, activeState) => { + expect( + shouldKeepMobileWidgetActionsMounted({ + isNearViewport: false, + actionsOpened: false, + detailsOpened: false, + isOpeningDetails: false, + isCompletingAction: false, + actionTriggerHasFocus: false, + ...activeState, + }), + ).toBe(true); + }); +}); + describe("isMobileContextActionVisible", () => { test("requires actions to opt into the read-only mobile surface", () => { const action = { key: "configure", label: "Configure", onClick: () => undefined }; diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts index 41917e4a1..08125d5bf 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts @@ -4,14 +4,21 @@ import type { WidgetContextMenuAction, WidgetMobilePresentation } from "@homarr/ export const resolveMobileItemPresentation = ( item: { kind: WidgetKind; height: number }, mobile: WidgetMobilePresentation | undefined, -) => ({ - width: mobile?.width ?? (item.kind === "app" ? 1 : 2), - height: mobile?.height ?? (item.kind === "app" ? 1 : Math.max(1, Math.min(item.height, 3))), - displayMode: mobile?.supportsCompactSummary ? ("mobileSummary" as const) : ("default" as const), - supportsDetails: mobile?.supportsDetailView === true, - eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"), - unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false), -}); +) => { + const supportsCompactSummary = mobile?.supportsCompactSummary === true; + const supportsDetails = mobile?.supportsDetailView === true; + const usesGenericSummary = supportsDetails && !supportsCompactSummary; + + return { + width: mobile?.width ?? (item.kind === "app" ? 1 : 2), + height: mobile?.height ?? (item.kind === "app" || usesGenericSummary ? 1 : Math.max(1, Math.min(item.height, 3))), + displayMode: supportsCompactSummary || usesGenericSummary ? ("mobileSummary" as const) : ("default" as const), + supportsDetails, + usesGenericSummary, + eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"), + unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false), + }; +}; export const shouldRenderMobileWidgetActions = ({ supportsDetails, @@ -23,5 +30,24 @@ export const shouldRenderMobileWidgetActions = ({ visibleContextActionCount: number; }) => supportsDetails || supportsRefresh || visibleContextActionCount > 0; +interface ShouldKeepMobileWidgetActionsMountedInput { + isNearViewport: boolean; + actionsOpened: boolean; + detailsOpened: boolean; + isOpeningDetails: boolean; + isCompletingAction: boolean; + actionTriggerHasFocus: boolean; +} + +export const shouldKeepMobileWidgetActionsMounted = ({ + isNearViewport, + actionsOpened, + detailsOpened, + isOpeningDetails, + isCompletingAction, + actionTriggerHasFocus, +}: ShouldKeepMobileWidgetActionsMountedInput) => + isNearViewport || actionsOpened || detailsOpened || isOpeningDetails || isCompletingAction || actionTriggerHasFocus; + export const isMobileContextActionVisible = (action: WidgetContextMenuAction) => action.mobileVisible === true && !action.hidden; diff --git a/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.spec.ts new file mode 100644 index 000000000..6ad42dd38 --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.spec.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { getMobilePreloadRootMargin, observeMobileViewportProximity } from "./mobile-viewport-observer"; + +class IntersectionObserverMock { + static instances: IntersectionObserverMock[] = []; + + readonly observedElements = new Set(); + readonly rootMargin: string; + disconnected = false; + + constructor( + private readonly callback: IntersectionObserverCallback, + options?: IntersectionObserverInit, + ) { + this.rootMargin = options?.rootMargin ?? "0px"; + IntersectionObserverMock.instances.push(this); + } + + observe(element: Element) { + this.observedElements.add(element); + } + + unobserve(element: Element) { + this.observedElements.delete(element); + } + + disconnect() { + this.disconnected = true; + this.observedElements.clear(); + } + + emit(element: Element, isIntersecting: boolean) { + this.callback([{ target: element, isIntersecting } as IntersectionObserverEntry], this as never); + } +} + +describe("observeMobileViewportProximity", () => { + beforeEach(() => { + IntersectionObserverMock.instances = []; + vi.stubGlobal("IntersectionObserver", IntersectionObserverMock); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 600, writable: true }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("shares one observer across cards and recreates it once when the viewport margin changes", () => { + const firstElement = document.createElement("div"); + const secondElement = document.createElement("div"); + const firstListener = vi.fn(); + const secondListener = vi.fn(); + + const stopObservingFirst = observeMobileViewportProximity(firstElement, firstListener); + const stopObservingSecond = observeMobileViewportProximity(secondElement, secondListener); + + expect(IntersectionObserverMock.instances).toHaveLength(1); + expect(IntersectionObserverMock.instances[0]?.observedElements).toEqual(new Set([firstElement, secondElement])); + expect(IntersectionObserverMock.instances[0]?.rootMargin).toBe(getMobilePreloadRootMargin(600)); + + IntersectionObserverMock.instances[0]?.emit(firstElement, true); + expect(firstListener).toHaveBeenCalledWith(true); + expect(secondListener).not.toHaveBeenCalled(); + + window.dispatchEvent(new Event("resize")); + expect(IntersectionObserverMock.instances).toHaveLength(1); + + window.innerHeight = 700; + window.dispatchEvent(new Event("resize")); + + expect(IntersectionObserverMock.instances).toHaveLength(2); + expect(IntersectionObserverMock.instances[0]?.disconnected).toBe(true); + expect(IntersectionObserverMock.instances[1]?.observedElements).toEqual(new Set([firstElement, secondElement])); + expect(IntersectionObserverMock.instances[1]?.rootMargin).toBe(getMobilePreloadRootMargin(700)); + + stopObservingFirst(); + stopObservingSecond(); + expect(IntersectionObserverMock.instances[1]?.disconnected).toBe(true); + }); + + test("removes one-time subscriptions after their card first becomes near", () => { + const element = document.createElement("div"); + const listener = vi.fn(); + + const stopObserving = observeMobileViewportProximity(element, listener, { once: true }); + IntersectionObserverMock.instances[0]?.emit(element, true); + + expect(listener).toHaveBeenCalledOnce(); + expect(IntersectionObserverMock.instances[0]?.disconnected).toBe(true); + stopObserving(); + }); + + test("mounts content immediately when IntersectionObserver is unavailable", () => { + vi.stubGlobal("IntersectionObserver", undefined); + const listener = vi.fn(); + + const stopObserving = observeMobileViewportProximity(document.createElement("div"), listener); + + expect(listener).toHaveBeenCalledWith(true); + stopObserving(); + }); +}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.ts b/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.ts new file mode 100644 index 000000000..61c657269 --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.ts @@ -0,0 +1,116 @@ +export const mobilePreloadViewportMultiplier = 2; + +export const getMobilePreloadRootMargin = (viewportHeight: number) => + `${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`; + +interface MobileViewportSubscription { + listener: (isNearViewport: boolean) => void; + once: boolean; +} + +interface ObserveMobileViewportOptions { + once?: boolean; +} + +const subscriptions = new Map>(); + +let observer: IntersectionObserver | null = null; +let observedRootMargin: string | null = null; +let isListeningForResize = false; + +const stopObservingIfIdle = () => { + if (subscriptions.size > 0) return; + + observer?.disconnect(); + observer = null; + observedRootMargin = null; + + if (isListeningForResize) { + window.removeEventListener("resize", handleViewportResize); + isListeningForResize = false; + } +}; + +const removeSubscription = (element: Element, subscription: MobileViewportSubscription) => { + const elementSubscriptions = subscriptions.get(element); + if (!elementSubscriptions) return; + + elementSubscriptions.delete(subscription); + if (elementSubscriptions.size > 0) return; + + subscriptions.delete(element); + observer?.unobserve(element); + stopObservingIfIdle(); +}; + +const handleIntersections: IntersectionObserverCallback = (entries) => { + for (const entry of entries) { + const elementSubscriptions = subscriptions.get(entry.target); + if (!elementSubscriptions) continue; + + for (const subscription of elementSubscriptions) { + subscription.listener(entry.isIntersecting); + if (entry.isIntersecting && subscription.once) { + removeSubscription(entry.target, subscription); + } + } + } +}; + +const createObserver = (rootMargin: string) => { + observer?.disconnect(); + observer = new IntersectionObserver(handleIntersections, { rootMargin }); + observedRootMargin = rootMargin; + + for (const element of subscriptions.keys()) { + observer.observe(element); + } +}; + +function handleViewportResize() { + const rootMargin = getMobilePreloadRootMargin(window.innerHeight); + if (rootMargin === observedRootMargin) return; + + createObserver(rootMargin); +} + +const ensureObserver = () => { + if (observer) return; + + createObserver(getMobilePreloadRootMargin(window.innerHeight)); + window.addEventListener("resize", handleViewportResize); + isListeningForResize = true; +}; + +export const observeMobileViewportProximity = ( + element: Element, + listener: (isNearViewport: boolean) => void, + options: ObserveMobileViewportOptions = {}, +) => { + if (typeof window === "undefined" || typeof IntersectionObserver === "undefined") { + listener(true); + return () => undefined; + } + + const subscription: MobileViewportSubscription = { + listener, + once: options.once ?? false, + }; + const elementSubscriptions = subscriptions.get(element); + + if (elementSubscriptions) { + elementSubscriptions.add(subscription); + } else { + subscriptions.set(element, new Set([subscription])); + } + + if (observer) { + observer.observe(element); + } else { + ensureObserver(); + } + + return () => { + removeSubscription(element, subscription); + }; +}; diff --git a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx index 60f50c234..312a60945 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx @@ -1,7 +1,7 @@ "use client"; import type { MutableRefObject } from "react"; -import { useMemo, useRef } from "react"; +import { useMemo, useRef, useState } from "react"; import { ActionIcon, Box, Button, Drawer, Loader, Modal, Stack } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { IconDotsVertical, IconMaximize, IconRefresh } from "@tabler/icons-react"; @@ -20,20 +20,40 @@ import type { SectionItem } from "~/app/[locale]/boards/_types"; import { BoardItemContent } from "../items/item-content"; import { useHasNestedDialog } from "./mobile-dialog-stack"; import classes from "./mobile-board.module.css"; -import { isMobileContextActionVisible, shouldRenderMobileWidgetActions } from "./mobile-presentation"; +import { + isMobileContextActionVisible, + shouldKeepMobileWidgetActionsMounted, + shouldRenderMobileWidgetActions, +} from "./mobile-presentation"; interface MobileWidgetActionsProps { item: SectionItem; supportsDetails: boolean; + detailsFromCard: boolean; + detailsOpened: boolean; + openDetails: () => void; + closeDetails: () => void; widgetStateRef: MutableRefObject | null>; + isNearViewport: boolean; } -export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => { +export const MobileWidgetActions = ({ + item, + supportsDetails, + detailsFromCard, + detailsOpened, + openDetails, + closeDetails, + widgetStateRef, + isNearViewport, +}: MobileWidgetActionsProps) => { const [actionsOpened, actionsDisclosure] = useDisclosure(false); - const [detailsOpened, detailsDisclosure] = useDisclosure(false); + const [isOpeningDetails, setIsOpeningDetails] = useState(false); + const [isCompletingAction, setIsCompletingAction] = useState(false); + const [actionTriggerHasFocus, setActionTriggerHasFocus] = useState(false); const actionTriggerRef = useRef(null); const detailsDialogRef = useRef(null); - const openDetailsAfterActionsRef = useRef(false); + const pendingContextActionRef = useRef<(() => void) | null>(null); const hasNestedDialog = useHasNestedDialog(detailsOpened, detailsDialogRef); const t = useI18n(); const settings = useSettings(); @@ -59,133 +79,169 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M const visibleContextActions = contextActions.filter(isMobileContextActionVisible); const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`); const hasActions = shouldRenderMobileWidgetActions({ - supportsDetails, + supportsDetails: supportsDetails && !detailsFromCard, supportsRefresh: queryKey !== undefined, visibleContextActionCount: visibleContextActions.length, }); + const shouldKeepMounted = shouldKeepMobileWidgetActionsMounted({ + isNearViewport, + actionsOpened, + detailsOpened, + isOpeningDetails, + isCompletingAction, + actionTriggerHasFocus, + }); - if (!hasActions) return null; + if ((!hasActions && !detailsOpened) || !shouldKeepMounted) return null; return ( <> - - - + {hasActions && ( + <> + setActionTriggerHasFocus(true)} + onBlur={() => setActionTriggerHasFocus(false)} + > + + - { - if (!openDetailsAfterActionsRef.current) return; - openDetailsAfterActionsRef.current = false; - detailsDisclosure.open(); - }} - closeButtonProps={{ - "aria-label": t("common.action.close"), - size: 44, - }} - > - - {supportsDetails && ( - - )} - {visibleContextActions.map((action) => { - const Icon = action.icon; - return ( - - ); - })} - {queryKey && } - - + { + if (isOpeningDetails) { + setIsOpeningDetails(false); + openDetails(); + return; + } - actionTriggerRef.current?.focus()} - closeButtonProps={{ - "aria-label": t("common.action.close"), - size: 44, - }} - styles={{ - content: { - display: "flex", - flexDirection: "column", - height: "100dvh", - paddingTop: "env(safe-area-inset-top)", - paddingLeft: "env(safe-area-inset-left)", - paddingRight: "env(safe-area-inset-right)", - }, - body: { - flex: "1 1 auto", - minHeight: 0, - overflowY: "auto", - paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))", - }, - }} - > - - - - + const pendingContextAction = pendingContextActionRef.current; + if (pendingContextAction) { + pendingContextActionRef.current = null; + setIsCompletingAction(false); + pendingContextAction(); + return; + } + + actionTriggerRef.current?.focus(); + }} + closeButtonProps={{ + "aria-label": t("common.action.close"), + size: 44, + }} + > + + {supportsDetails && !detailsFromCard && ( + + )} + {visibleContextActions.map((action) => { + const Icon = action.icon; + return ( + + ); + })} + {queryKey && } + + + + )} + + {supportsDetails && ( + { + if (!detailsFromCard) { + actionTriggerRef.current?.focus(); + } + }} + closeButtonProps={{ + "aria-label": t("common.action.close"), + size: 44, + }} + styles={{ + content: { + display: "flex", + flexDirection: "column", + height: "100dvh", + paddingTop: "env(safe-area-inset-top)", + paddingLeft: "env(safe-area-inset-left)", + paddingRight: "env(safe-area-inset-right)", + }, + body: { + flex: "1 1 auto", + minHeight: 0, + overflowY: "auto", + paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))", + }, + }} + > + + + + + )} ); }; 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.spec.ts b/apps/nextjs/src/components/board/use-mobile-board.spec.ts index 35a8a95b6..df8827ac5 100644 --- a/apps/nextjs/src/components/board/use-mobile-board.spec.ts +++ b/apps/nextjs/src/components/board/use-mobile-board.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "vitest"; -import { resolveMobileBoardViewport } from "./use-mobile-board"; +import { + resolveIsAutomaticMobileBoard, + resolveMobileBoardViewport, + shouldShowMobileBoardViewportSkeleton, +} from "./use-mobile-board"; describe("resolveMobileBoardViewport", () => { test("keeps phones mobile regardless of their viewport width", () => { @@ -56,3 +60,26 @@ describe("resolveMobileBoardViewport", () => { }); }); }); + +describe("resolveIsAutomaticMobileBoard", () => { + test.each([ + [true, true, true], + [true, false, false], + [false, true, false], + [false, false, false], + ])( + "requires both a mobile viewport and the server setting (viewport: %s, setting: %s)", + (isMobileViewport, enableAutomaticMobileLayout, expected) => { + expect(resolveIsAutomaticMobileBoard({ isMobileViewport, enableAutomaticMobileLayout })).toBe(expected); + }, + ); + + test("does not show the tablet resolution skeleton in legacy mode", () => { + expect( + shouldShowMobileBoardViewportSkeleton({ + enableAutomaticMobileLayout: false, + isResolved: false, + }), + ).toBe(false); + }); +}); diff --git a/apps/nextjs/src/components/board/use-mobile-board.tsx b/apps/nextjs/src/components/board/use-mobile-board.tsx index 7197aac22..1972206f2 100644 --- a/apps/nextjs/src/components/board/use-mobile-board.tsx +++ b/apps/nextjs/src/components/board/use-mobile-board.tsx @@ -5,6 +5,8 @@ import { createContext, useContext, useEffect, useState } from "react"; import { Box, Group, Skeleton, Stack } from "@mantine/core"; import { useMediaQuery } from "@mantine/hooks"; +import { useSettings } from "@homarr/settings"; + export const mobileBoardMediaQuery = "(max-width: 48em)"; export type MobileBoardDeviceClass = "phone" | "tablet" | "desktop"; @@ -70,6 +72,7 @@ export const MobileBoardViewportProvider = ({ initialDeviceClass, children, }: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => { + const { enableAutomaticMobileLayout } = useSettings(); const [hasResolvedClientWidth, setHasResolvedClientWidth] = useState(false); const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", { getInitialValueInEffect: true, @@ -87,11 +90,42 @@ export const MobileBoardViewportProvider = ({ return ( - {viewport.isResolved ? children : } + {shouldShowMobileBoardViewportSkeleton({ + enableAutomaticMobileLayout, + isResolved: viewport.isResolved, + }) ? ( + + ) : ( + children + )} ); }; export const useMobileBoardViewport = () => useContext(MobileBoardViewportContext); -export const useIsMobileBoard = () => useMobileBoardViewport().isMobile; +export const resolveIsAutomaticMobileBoard = ({ + isMobileViewport, + enableAutomaticMobileLayout, +}: { + isMobileViewport: boolean; + enableAutomaticMobileLayout: boolean; +}) => isMobileViewport && enableAutomaticMobileLayout; + +export const shouldShowMobileBoardViewportSkeleton = ({ + enableAutomaticMobileLayout, + isResolved, +}: { + enableAutomaticMobileLayout: boolean; + isResolved: boolean; +}) => enableAutomaticMobileLayout && !isResolved; + +export const useIsMobileBoard = () => { + const viewport = useMobileBoardViewport(); + const { enableAutomaticMobileLayout } = useSettings(); + + return resolveIsAutomaticMobileBoard({ + isMobileViewport: viewport.isMobile, + enableAutomaticMobileLayout, + }); +}; diff --git a/apps/nextjs/src/components/language/current-language-combobox.tsx b/apps/nextjs/src/components/language/current-language-combobox.tsx index 54d9a0f98..9a8dbcb52 100644 --- a/apps/nextjs/src/components/language/current-language-combobox.tsx +++ b/apps/nextjs/src/components/language/current-language-combobox.tsx @@ -1,5 +1,6 @@ "use client"; +import { requestBoardEditAction } from "@homarr/boards/edit-mode"; import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client"; import { LanguageCombobox } from "./language-combobox"; @@ -16,7 +17,7 @@ export const CurrentLanguageCombobox = ({ width, withinPortal }: CurrentLanguage return ( requestBoardEditAction(() => changeLocale(locale))} isPending={isPending} width={width} withinPortal={withinPortal} diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx index 9b2e34fea..2cfed0b5b 100644 --- a/apps/nextjs/src/components/user-avatar-menu.tsx +++ b/apps/nextjs/src/components/user-avatar-menu.tsx @@ -21,9 +21,17 @@ interface UserAvatarMenuProps { children: ReactNode; availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"]; isDockerEnabled?: boolean; + opened: boolean; + onOpenChange: (opened: boolean) => void; } -export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => { +export const UserAvatarMenu = ({ + children, + availableUpdates, + isDockerEnabled, + opened, + onOpenChange, +}: UserAvatarMenuProps) => { const t = useScopedI18n("common.userAvatar.menu"); const session = useSession(); @@ -40,7 +48,7 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: return ( // We use keepMounted so we can add event listeners to prevent navigating away without saving the board - + }> diff --git a/e2e/mobile-board.spec.ts b/e2e/mobile-board.spec.ts index 959c57065..6c929b16e 100644 --- a/e2e/mobile-board.spec.ts +++ b/e2e/mobile-board.spec.ts @@ -1,6 +1,7 @@ +import type { Page } from "@playwright/test"; import { chromium, devices, expect } from "@playwright/test"; import { createId } from "@paralleldrive/cuid2"; -import SuperJSON from "superjson"; +import { stringify } from "superjson"; import { describe, test } from "vitest"; import { eq } from "drizzle-orm"; @@ -18,9 +19,34 @@ const adminCredentials = { 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) { + if (enabled) { + await toggle.check(); + } else { + await toggle.uncheck(); + } + + await toggle.locator("xpath=ancestor::form").getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Settings saved successfully")).toBeVisible(); + } + + await page.reload(); + if (enabled) { + await expect(page.getByRole("switch", { name: "Use automatic mobile layout" })).toBeChecked(); + } else { + await expect(page.getByRole("switch", { name: "Use automatic mobile layout" })).not.toBeChecked(); + } +}; + const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => { const boardId = createId(); - const layoutId = createId(); + const desktopLayoutId = createId(); + const mobileLayoutId = createId(); const firstSectionId = createId(); const secondSectionId = createId(); @@ -29,13 +55,22 @@ const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => { name: boardName, creatorId: userId, }); - await db.insert(sqliteSchema.layouts).values({ - id: layoutId, - name: "Desktop", - boardId, - columnCount: 12, - breakpoint: 0, - }); + 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: firstSectionId, @@ -60,22 +95,33 @@ const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => { return { id, boardId, - kind: index === 0 ? ("notebook" as const) : ("clock" as const), - options: SuperJSON.stringify(index === 0 ? { content: "

Mobile notes

" } : {}), - advancedOptions: SuperJSON.stringify({}), + kind: index === 0 ? ("notebook" as const) : index === 1 ? ("rssFeed" as const) : ("clock" as const), + options: stringify(index === 0 ? { content: "

Mobile notes

" } : {}), + advancedOptions: stringify({}), }; }); await db.insert(sqliteSchema.items).values(itemRows); await db.insert(sqliteSchema.itemLayouts).values( - itemRows.map((item, index) => ({ - itemId: item.id, - layoutId, - sectionId: index < 18 ? firstSectionId : secondSectionId, - xOffset: (index % 6) * 2, - yOffset: Math.floor((index % 18) / 6) * 2, - width: 2, - height: 2, - })), + itemRows.flatMap((item, index) => [ + { + itemId: item.id, + layoutId: desktopLayoutId, + sectionId: index < 18 ? firstSectionId : secondSectionId, + xOffset: (index % 6) * 2, + yOffset: Math.floor((index % 18) / 6) * 2, + width: 2, + height: 2, + }, + { + itemId: item.id, + layoutId: mobileLayoutId, + sectionId: index < 18 ? firstSectionId : secondSectionId, + xOffset: (index % 2) * 2, + yOffset: Math.floor((index % 18) / 2) * 2, + width: 2, + height: 2, + }, + ]), ); await db .update(sqliteSchema.users) @@ -112,6 +158,7 @@ describe("Automatic mobile board", () => { await loginPage.locator("#password").fill(adminCredentials.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 mobileViewports = [ @@ -150,6 +197,7 @@ describe("Automatic mobile board", () => { await expect(page.locator("[data-mobile-board-item]"), viewport.name).toHaveCount(36); await expect(page.locator(".grid-stack-item"), viewport.name).toHaveCount(0); await expect(page.getByRole("button", { name: /search/i }), viewport.name).toBeVisible(); + await expect(page.getByRole("button", { name: "Edit item", exact: true }), viewport.name).toHaveCount(0); const layoutMetrics = await page.evaluate(() => { const grids = Array.from(document.querySelectorAll("[data-mobile-board] > div")); @@ -201,6 +249,10 @@ describe("Automatic mobile board", () => { } if (viewport.width === 390) { + await page.getByRole("button", { name: `Current board: ${boardName}` }).click(); + await expect(page.getByRole("menuitem", { name: boardName })).toBeDisabled(); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "More" }).click(); const moreDrawer = page.getByRole("dialog", { name: "More" }); await expect(moreDrawer.getByRole("button", { name: "Second section" })).toBeVisible(); @@ -209,12 +261,20 @@ describe("Automatic mobile board", () => { const notebookActions = page.getByRole("button", { name: /Actions for Notebook/i }); await notebookActions.click(); - await page.getByRole("button", { name: "Open widget details" }).click(); + await page.getByRole("button", { name: "Open widget details", exact: true }).click(); const details = page.getByRole("dialog", { name: "Notebook" }); await expect(details).toBeVisible(); + await expect(details.getByRole("button", { name: "Edit" })).toBeVisible(); await details.getByRole("button", { name: "Close" }).click(); await expect(notebookActions).toBeFocused(); + const rssSummary = page.getByRole("button", { name: "Open widget details: RSS feeds" }); + await rssSummary.click(); + const rssDetails = page.getByRole("dialog", { name: "RSS feeds" }); + await expect(rssDetails).toBeVisible(); + await rssDetails.getByRole("button", { name: "Close" }).click(); + await expect(rssSummary).toBeFocused(); + const rtlOverflow = await page.evaluate(() => { document.documentElement.dir = "rtl"; return document.documentElement.scrollWidth - document.documentElement.clientWidth; @@ -233,7 +293,56 @@ describe("Automatic mobile board", () => { await desktopPage.goto(`${baseUrl}/boards/${boardName}`); await expect(desktopPage.locator("[data-mobile-board]")).toHaveCount(0); await expect(desktopPage.locator(".grid-stack-item")).toHaveCount(36); + + await desktopPage.getByRole("button", { name: "Edit item", exact: true }).click(); + await desktopPage.setViewportSize({ width: 390, height: 844 }); + await expect(desktopPage.locator("[data-mobile-board]")).toBeVisible(); + await desktopPage.getByRole("button", { name: "More" }).click(); + await desktopPage.getByRole("dialog", { name: "More" }).getByRole("link", { name: "Settings" }).click(); + const unsavedChangesDialog = desktopPage.getByRole("dialog", { name: "Unsaved changes" }); + await expect(unsavedChangesDialog).toBeVisible(); + await desktopPage.keyboard.press("Escape"); + await expect(unsavedChangesDialog).toBeHidden(); + await desktopPage.goBack(); + await expect(unsavedChangesDialog).toBeVisible(); + await desktopPage.goBack(); + await expect(desktopPage.getByRole("dialog", { name: "Unsaved changes" })).toHaveCount(1); + await desktopPage.keyboard.press("Escape"); + await expect(unsavedChangesDialog).toBeHidden(); + await desktopPage.setViewportSize({ width: 800, height: 900 }); + await desktopPage.getByRole("button", { name: "Edit item", exact: true }).click(); + await expect(desktopPage.getByText("The board was successfully saved")).toBeVisible(); + await expect + .poll(() => desktopPage.evaluate(() => window.history.state?.["__homarrBoardEditGuard"] ?? null)) + .toBeNull(); + + await setAutomaticMobileLayoutAsync(desktopPage, false); await desktopContext.close(); + + const legacyMobileContext = await browser.newContext({ + storageState, + viewport: { width: 390, height: 844 }, + userAgent: devices["iPhone 13"].userAgent, + hasTouch: true, + isMobile: true, + }); + const legacyMobilePage = await legacyMobileContext.newPage(); + await legacyMobilePage.goto(`${baseUrl}/boards/${boardName}`); + await expect(legacyMobilePage.locator("[data-mobile-board]")).toHaveCount(0); + await expect(legacyMobilePage.locator(".grid-stack-item")).toHaveCount(36); + await expect(legacyMobilePage.getByRole("button", { name: "Edit item" })).toBeVisible(); + await expect(legacyMobilePage.getByRole("button", { name: "More" })).toHaveCount(0); + await legacyMobilePage.getByRole("button", { name: "First section" }).click(); + await legacyMobilePage.getByRole("button", { name: "Second section" }).click(); + await expect + .poll(async () => + legacyMobilePage + .locator(".grid-stack") + .first() + .evaluate((element) => getComputedStyle(element).getPropertyValue("--gridstack-column-count").trim()), + ) + .toBe("4"); + await legacyMobileContext.close(); } finally { await loginContext.close(); await browser.close(); diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 05bc30ae7..9795dc937 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -1913,9 +1913,6 @@ const getHomeIdBoardAsync = async ( user: InferSelectModel | null, deviceType: DeviceType, ) => { - // Mobile home-board selection remains device-based because the server does - // not know the resolved CSS viewport. Tablet rendering is resolved against - // 48em on the client and must not be guessed here. const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId"; if (!user) { @@ -1937,7 +1934,8 @@ const getHomeIdBoardAsync = async ( and( eq(groupMembers.userId, user.id), not(eq(groups.name, everyoneGroup)), - or(not(isNull(groups[settingKey])), not(isNull(groups.homeBoardId))), + not(isNull(groups[settingKey])), + not(isNull(groups.homeBoardId)), ), ) .orderBy(asc(groups.position)) diff --git a/packages/api/src/router/serverSettings.ts b/packages/api/src/router/serverSettings.ts index 9783e0ae9..dc117fc0c 100644 --- a/packages/api/src/router/serverSettings.ts +++ b/packages/api/src/router/serverSettings.ts @@ -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) @@ -103,10 +104,19 @@ 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]; + const existing = await ctx.db.query.serverSettings.findFirst({ + where: eq(serverSettings.settingKey, input.settingsKey), + }); + + if (existing) { + await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, next); + } else { + await insertServerSettingByKeyAsync(ctx.db, input.settingsKey, next); + } }), initSettings: onboardingProcedure .requiresStep("settings") diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index 23aca55d0..625325dcf 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -549,64 +549,6 @@ describe("getHomeBoard should return home board", () => { }); expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view"); }); - test.each([ - { deviceType: "mobile", expectedBoard: "mobile-home" }, - { deviceType: "tablet", expectedBoard: "desktop-home" }, - ] as const)( - "should use the $expectedBoard preference for a $deviceType device", - async ({ deviceType, expectedBoard }) => { - // Arrange - const db = createDb(); - const caller = boardRouter.createCaller({ db, deviceType, session: defaultSession }); - - const mobileBoardProps = await createFullBoardAsync(db, "mobile-home"); - const desktopBoardProps = await createFullBoardAsync(db, "desktop-home"); - await db - .update(users) - .set({ - homeBoardId: desktopBoardProps.boardId, - mobileHomeBoardId: mobileBoardProps.boardId, - }) - .where(eq(users.id, defaultCreatorId)); - - // Act - const result = await caller.getHomeBoard(); - - // Assert - expectInputToBeFullBoardWithName(result, { - ...(expectedBoard === "mobile-home" ? mobileBoardProps : desktopBoardProps), - name: expectedBoard, - }); - }, - ); - test("should return a mobile-only group home board", async () => { - // Arrange - const db = createDb(); - const caller = boardRouter.createCaller({ db, deviceType: "mobile", session: defaultSession }); - - const mobileBoardProps = await createFullBoardAsync(db, "group-mobile-home"); - const groupId = createId(); - await db.insert(groups).values({ - id: groupId, - name: "mobile group", - position: 1, - homeBoardId: null, - mobileHomeBoardId: mobileBoardProps.boardId, - }); - await db.insert(groupMembers).values({ - userId: defaultCreatorId, - groupId, - }); - - // Act - const result = await caller.getHomeBoard(); - - // Assert - expectInputToBeFullBoardWithName(result, { - name: "group-mobile-home", - ...mobileBoardProps, - }); - }); test("should return global home board when user doesn't have one", async () => { // Arrange const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); @@ -2077,12 +2019,9 @@ const expectInputToBeFullBoardWithName = ( }; const createFullBoardAsync = async (db: Database, name: string) => { - await db - .insert(users) - .values({ - id: defaultCreatorId, - }) - .onConflictDoNothing(); + await db.insert(users).values({ + id: defaultCreatorId, + }); const boardId = createId(); await db.insert(boards).values({ diff --git a/packages/api/src/router/test/serverSettings.spec.ts b/packages/api/src/router/test/serverSettings.spec.ts index c045b1288..d3d43b213 100644 --- a/packages/api/src/router/test/serverSettings.spec.ts +++ b/packages/api/src/router/test/serverSettings.spec.ts @@ -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({ @@ -103,6 +130,27 @@ 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 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); + }); + test("updateBoardSettings should insert settings when defaults are not persisted", async () => { const db = createDb(); const caller = serverSettingsRouter.createCaller({ @@ -145,6 +193,7 @@ describe("board settings API", () => { await caller.updateBoardSettings({ homeBoardId: boardId }); const result = await caller.updateBoardSettings({ mobileHomeBoardId: boardId, + enableAutomaticMobileLayout: false, enableStatusByDefault: false, }); @@ -152,6 +201,7 @@ describe("board settings API", () => { ...defaultServerSettings.board, homeBoardId: boardId, mobileHomeBoardId: boardId, + enableAutomaticMobileLayout: false, enableStatusByDefault: false, }); const dbSettings = await db.select().from(serverSettings); diff --git a/packages/api/src/settle-integrations.spec.ts b/packages/api/src/settle-integrations.spec.ts index ca032b700..efddcf136 100644 --- a/packages/api/src/settle-integrations.spec.ts +++ b/packages/api/src/settle-integrations.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; -import { settleIntegrationQueries } from "./settle-integrations"; +import { getIntegrationQueryProvenance, settleIntegrationQueries } from "./settle-integrations"; const integrations = [ { id: "first", name: "First", kind: "mock" }, @@ -76,3 +76,19 @@ describe("settleIntegrationQueries", () => { ).resolves.toEqual([]); }); }); + +describe("getIntegrationQueryProvenance", () => { + it("counts dropped and stale integration results independently", () => { + expect(getIntegrationQueryProvenance(4, [{ isStale: false }, { isStale: true }, { isStale: true }])).toEqual({ + failedIntegrationCount: 1, + staleIntegrationCount: 2, + }); + }); + + it("does not report failures for complete current results", () => { + expect(getIntegrationQueryProvenance(2, [{ isStale: false }, {}])).toEqual({ + failedIntegrationCount: 0, + staleIntegrationCount: 0, + }); + }); +}); diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts index c095c7715..4cd282d0c 100644 --- a/packages/api/src/settle-integrations.ts +++ b/packages/api/src/settle-integrations.ts @@ -20,6 +20,19 @@ interface Options { throwOnAllFailures?: boolean; } +export interface IntegrationQueryProvenance { + failedIntegrationCount: number; + staleIntegrationCount: number; +} + +export const getIntegrationQueryProvenance = ( + selectedIntegrationCount: number, + results: readonly { isStale?: boolean }[], +): IntegrationQueryProvenance => ({ + failedIntegrationCount: Math.max(0, selectedIntegrationCount - results.length), + staleIntegrationCount: results.filter((result) => result.isStale === true).length, +}); + export async function settleIntegrationQueries( integrations: TIntegration[], fn: (integration: TIntegration) => Promise, diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts index 5df3a9f06..09e6c077e 100644 --- a/packages/api/src/test/mcp.spec.ts +++ b/packages/api/src/test/mcp.spec.ts @@ -67,6 +67,7 @@ const MCP_TOOL_ALLOWLIST = { "board_deleteBoard", "board_duplicateBoard", "board_renameBoard", + "board_resetLayout", "board_savePartialBoardSettings", "board_setHomeBoard", "board_setMobileHomeBoard", 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..a4ebfcb25 --- /dev/null +++ b/packages/boards/src/context.spec.ts @@ -0,0 +1,36 @@ +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("keeps every saved layout available for reversible mutations", () => { + expect(getBoardLayouts(createBoard())).toEqual(["small", "medium", "large"]); + }); +}); diff --git a/packages/boards/src/edit-mode.spec.ts b/packages/boards/src/edit-mode.spec.ts new file mode 100644 index 000000000..25bf237f8 --- /dev/null +++ b/packages/boards/src/edit-mode.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { BoardEditAction, BoardEditActionEvent } from "./edit-mode"; +import { boardEditActionEventName, requestBoardEditAction } from "./edit-mode"; + +describe("requestBoardEditAction", () => { + test("runs the action immediately when no edit guard is mounted", () => { + const action = vi.fn(); + + requestBoardEditAction(action); + + expect(action).toHaveBeenCalledOnce(); + }); + + test("lets an edit guard defer the action until the user confirms", () => { + const action = vi.fn(); + let deferredAction: BoardEditAction | undefined; + const listener = (event: Event) => { + event.preventDefault(); + deferredAction = (event as BoardEditActionEvent).detail.action; + }; + document.addEventListener(boardEditActionEventName, listener, { once: true }); + + requestBoardEditAction(action); + expect(action).not.toHaveBeenCalled(); + + void deferredAction?.(); + expect(action).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/boards/src/edit-mode.tsx b/packages/boards/src/edit-mode.tsx index 6ca606b99..ce5395b30 100644 --- a/packages/boards/src/edit-mode.tsx +++ b/packages/boards/src/edit-mode.tsx @@ -6,6 +6,28 @@ import { useDisclosure } from "@mantine/hooks"; const EditModeContext = createContext | null>(null); +export const boardEditActionEventName = "homarr:board-edit-action"; + +export type BoardEditActionResult = boolean | void; +export type BoardEditAction = () => BoardEditActionResult | Promise; +export type BoardEditActionEvent = CustomEvent<{ action: BoardEditAction }>; + +export const requestBoardEditAction = (action: BoardEditAction) => { + if (typeof document === "undefined") { + void action(); + return; + } + + const event: BoardEditActionEvent = new CustomEvent(boardEditActionEventName, { + cancelable: true, + detail: { action }, + }); + + if (document.dispatchEvent(event)) { + void action(); + } +}; + const noop = () => undefined; const readOnlyEditMode = [false, { open: noop, close: noop, toggle: noop, set: noop }] satisfies ReturnType< typeof useDisclosure diff --git a/packages/db/migrations/seed.spec.ts b/packages/db/migrations/seed.spec.ts new file mode 100644 index 000000000..b9139ace1 --- /dev/null +++ b/packages/db/migrations/seed.spec.ts @@ -0,0 +1,106 @@ +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.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..679db8752 100644 --- a/packages/db/queries/server-setting.ts +++ b/packages/db/queries/server-setting.ts @@ -1,4 +1,4 @@ -import SuperJSON from "superjson"; +import { parse, stringify } from "superjson"; import type { ServerSettings } from "@homarr/server-settings"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; @@ -7,23 +7,71 @@ import type { Database } from ".."; import { eq } from ".."; import { serverSettings } from "../schema"; +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(); - 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); acc[settingKey] = { ...defaultServerSettings[settingKey], - ...SuperJSON.parse(setting.value), - }; + ...persistedSettings, + } as never; return acc; }, {} as ServerSettings); + + const persistedBoardSettings = settings.find((setting) => setting.settingKey === "board"); + const parsedBoardSettings = persistedBoardSettings + ? parse>(persistedBoardSettings.value) + : {}; + + if (typeof parsedBoardSettings.enableAutomaticMobileLayout !== "boolean") { + mergedSettings.board.enableAutomaticMobileLayout = await getAutomaticMobileLayoutUpgradeDefaultAsync(db); + } + + return mergedSettings; }; export const getServerSettingByKeyAsync = async (db: Database, key: TKey) => { @@ -32,10 +80,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 +115,7 @@ export const updateServerSettingByKeyAsync = async { await db.insert(serverSettings).values({ settingKey: key, - value: SuperJSON.stringify(value), + value: stringify(value), }); }; diff --git a/packages/modals/src/confirm-modal.tsx b/packages/modals/src/confirm-modal.tsx index efea408e7..3cac2bfa4 100644 --- a/packages/modals/src/confirm-modal.tsx +++ b/packages/modals/src/confirm-modal.tsx @@ -16,6 +16,7 @@ export interface ConfirmModalProps { children: ReactNode; onConfirm?: () => MaybePromise; onCancel?: () => MaybePromise; + onClose?: () => void; closeOnConfirm?: boolean; closeOnCancel?: boolean; cancelProps?: ButtonProps & ComponentPropsWithoutRef<"button">; diff --git a/packages/modals/src/index.tsx b/packages/modals/src/index.tsx index ef365647c..d21a59061 100644 --- a/packages/modals/src/index.tsx +++ b/packages/modals/src/index.tsx @@ -128,6 +128,7 @@ interface OpenModalOptions { keepMounted?: boolean; onClose?: () => void; title?: stringOrTranslation; + onClose?: () => void; } export const useModalAction = (modal: TModal) => { @@ -148,6 +149,10 @@ export const useConfirmModal = () => { const { openModal } = useModalAction(ConfirmModal); return { - openConfirmModal: (props: ConfirmModalProps) => openModal(props, { title: props.title }), + openConfirmModal: (props: ConfirmModalProps) => + openModal(props, { + title: props.title, + onClose: props.onClose, + }), }; }; 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/packages/spotlight/package.json b/packages/spotlight/package.json index 7781ba78a..1239f3bfb 100644 --- a/packages/spotlight/package.json +++ b/packages/spotlight/package.json @@ -26,6 +26,7 @@ "dependencies": { "@homarr/api": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0", + "@homarr/boards": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0", diff --git a/packages/spotlight/src/preferences/use-user-preference.ts b/packages/spotlight/src/preferences/use-user-preference.ts index fd61dc26e..1fe3d64b8 100644 --- a/packages/spotlight/src/preferences/use-user-preference.ts +++ b/packages/spotlight/src/preferences/use-user-preference.ts @@ -7,6 +7,7 @@ import type { DayOfWeek } from "@mantine/dates"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; +import { requestBoardEditAction } from "@homarr/boards/edit-mode"; import type { ColorScheme } from "@homarr/definitions"; import { showErrorNotification } from "@homarr/notifications"; import type { SettingsContextProps } from "@homarr/settings/creator"; @@ -160,7 +161,7 @@ export const useUserPreferences = () => { const clientSetters: Partial void>> = { colorScheme: (value) => setColorScheme(value as ColorScheme), - locale: (value) => changeLocale(value as typeof currentLocale), + locale: (value) => requestBoardEditAction(() => changeLocale(value as typeof currentLocale)), fullPreferencesPage: () => undefined, }; diff --git a/packages/widgets/src/bookmarks/component.tsx b/packages/widgets/src/bookmarks/component.tsx index d16e08ee4..13e211f53 100644 --- a/packages/widgets/src/bookmarks/component.tsx +++ b/packages/widgets/src/bookmarks/component.tsx @@ -111,7 +111,7 @@ export default function BookmarksWidget({ return ( {options.title.length > 0 && ( - + <Title order={4} px="0.25rem" lineClamp={1}> {options.title} )} @@ -283,14 +283,14 @@ const AdvancedBookmarksLayout = ({ style={{ width: 40, height: 40, flex: "0 0 auto" }} /> - + {app.name} {getBookmarkHostname(app.href)} {app.description && ( - + {app.description} )} diff --git a/packages/widgets/src/common/query-state.tsx b/packages/widgets/src/common/query-state.tsx new file mode 100644 index 000000000..2347a8b57 --- /dev/null +++ b/packages/widgets/src/common/query-state.tsx @@ -0,0 +1,73 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Box, Center, Tooltip } from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; + +import { useI18n } from "@homarr/translation/client"; + +interface WidgetQueryProvenance { + error?: unknown; + failedIntegrationCount?: number; + staleIntegrationCount?: number; + expectedIntegrationCount?: number; + receivedIntegrationCount?: number; +} + +export const hasWidgetDataWarning = ({ + error, + failedIntegrationCount = 0, + staleIntegrationCount = 0, + expectedIntegrationCount, + receivedIntegrationCount, +}: WidgetQueryProvenance) => + Boolean(error) || + failedIntegrationCount > 0 || + staleIntegrationCount > 0 || + (expectedIntegrationCount !== undefined && + receivedIntegrationCount !== undefined && + receivedIntegrationCount < expectedIntegrationCount); + +export const throwOnInitialQueryError = (error: unknown, hasData: boolean) => { + if (error && !hasData) throw error; +}; + +interface WidgetDataStateProps { + children: ReactNode; + hasWarning: boolean; +} + +export function WidgetDataState({ children, hasWarning }: WidgetDataStateProps) { + const t = useI18n(); + const label = t("board.mobile.dataWarning"); + + return ( + + {hasWarning && ( + +
+ +
+
+ )} + {children} +
+ ); +} diff --git a/packages/widgets/src/custom-api/index.ts b/packages/widgets/src/custom-api/index.ts index a1fcf531d..78a932126 100644 --- a/packages/widgets/src/custom-api/index.ts +++ b/packages/widgets/src/custom-api/index.ts @@ -6,7 +6,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("customApi", { icon: IconApi, - mobile: { supportsDetailView: true }, + mobile: { supportsDetailView: true, unmountWhenOffscreen: true }, queryKey: [["widget", "customApi"]], createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/dns-hole/controls/index.ts b/packages/widgets/src/dns-hole/controls/index.ts index 11d52c999..faf737a05 100644 --- a/packages/widgets/src/dns-hole/controls/index.ts +++ b/packages/widgets/src/dns-hole/controls/index.ts @@ -10,7 +10,7 @@ export const widgetKind = "dnsHoleControls"; export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { supportsAdvancedFocus: false, icon: IconDeviceGamepad, - mobile: { supportsDetailView: true }, + mobile: { supportsDetailView: true, unmountWhenOffscreen: true }, queryKey: [["widget", "dnsHole"]], refetchInterval: 5, createOptions() { diff --git a/packages/widgets/src/dns-hole/summary/index.ts b/packages/widgets/src/dns-hole/summary/index.ts index dcc77a041..050b45cde 100644 --- a/packages/widgets/src/dns-hole/summary/index.ts +++ b/packages/widgets/src/dns-hole/summary/index.ts @@ -10,6 +10,7 @@ export const widgetKind = "dnsHoleSummary"; export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { supportsAdvancedFocus: false, icon: IconAd, + mobile: { unmountWhenOffscreen: true }, queryKey: [["widget", "dnsHole"]], refetchInterval: 5, createOptions() { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06968df00..fdc849556 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 @@ -2572,6 +2575,9 @@ importers: '@homarr/auth': specifier: workspace:^0.1.0 version: link:../auth + '@homarr/boards': + specifier: workspace:^0.1.0 + version: link:../boards '@homarr/common': specifier: workspace:^0.1.0 version: link:../common