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 f0a978547..448119508 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,18 +4,14 @@ import { shouldPrefetchWidgetForRequest } from "./_prefetch-policy"; describe("shouldPrefetchWidgetForRequest", () => { test.each(["mobile", "tablet"])("defers downloads for %s requests", (deviceType) => { - expect(shouldPrefetchWidgetForRequest("downloads", deviceType, true)).toBe(false); + expect(shouldPrefetchWidgetForRequest("downloads", deviceType)).toBe(false); }); test("keeps downloads prefetched for desktop requests", () => { - expect(shouldPrefetchWidgetForRequest("downloads", undefined, true)).toBe(true); - }); - - test("keeps downloads prefetched for legacy responsive mobile boards", () => { - expect(shouldPrefetchWidgetForRequest("downloads", "mobile", false)).toBe(true); + expect(shouldPrefetchWidgetForRequest("downloads", undefined)).toBe(true); }); test.each(["app", "bookmarks"] as const)("keeps eager %s data prefetched on mobile", (kind) => { - expect(shouldPrefetchWidgetForRequest(kind, "mobile", true)).toBe(true); + expect(shouldPrefetchWidgetForRequest(kind, "mobile")).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 b442f7cf5..c5a89a754 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts @@ -1,11 +1,7 @@ import type { WidgetKind } from "@homarr/definitions"; -export const shouldPrefetchWidgetForRequest = ( - kind: WidgetKind, - deviceType: string | undefined, - enableAutomaticMobileLayout: boolean, -) => { +export const shouldPrefetchWidgetForRequest = (kind: WidgetKind, deviceType: string | undefined) => { const isMobileDevice = deviceType === "mobile" || deviceType === "tablet"; - return kind !== "downloads" || !isMobileDevice || !enableAutomaticMobileLayout; + return kind !== "downloads" || !isMobileDevice; }; diff --git a/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx b/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx index daccdcccf..7b4551ff5 100644 --- a/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx @@ -48,22 +48,13 @@ export const BoardSwitcherLogo = () => { {boards.map((availableBoard) => { const isCurrent = availableBoard.id === board.id; - if (isCurrent) { - return ( - } aria-current="page" disabled> - - {availableBoard.name} - - - ); - } - return ( } + leftSection={isCurrent ? : } + aria-current={isCurrent ? "page" : undefined} > {availableBoard.name} diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index e343b956d..03bb12d5c 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -115,7 +115,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/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/deferred-mobile-item.tsx b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx index b66894c39..de0fa9d98 100644 --- a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx +++ b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx @@ -5,56 +5,66 @@ 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 { getMobilePreloadRootMargin, mobilePreloadViewportMultiplier } from "./mobile-viewport-observer"; +export const mobilePreloadViewportMultiplier = 2; -export const DeferredMobileItem = ({ - eager, - unmountWhenOffscreen, - onNearViewportChange, - children, -}: DeferredMobileItemProps) => { +export const getMobilePreloadRootMargin = (viewportHeight: number) => + `${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`; + +export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, 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) { + if (!element || typeof IntersectionObserver === "undefined") { setHasMounted(true); - onNearViewportChange?.(true); return; } - return observeMobileViewportProximity( - element, - (isNearViewport) => { - onNearViewportChange?.(isNearViewport); + let observer: IntersectionObserver | null = null; + const observe = () => { + if (hasMountedOnceRef.current && !unmountWhenOffscreen) return; + observer?.disconnect(); + observer = new IntersectionObserver( + ([entry]) => { + const isNearViewport = entry?.isIntersecting ?? false; - if (isNearViewport) { - setHasMounted(true); - return; - } + if (isNearViewport) { + hasMountedOnceRef.current = true; + setHasMounted(true); + if (!unmountWhenOffscreen) observer?.disconnect(); + return; + } - if (unmountWhenOffscreen && !element.contains(document.activeElement)) { - setHasMounted(false); - } - }, - { once: !unmountWhenOffscreen }, - ); - }, [eager, onNearViewportChange, unmountWhenOffscreen]); + 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]); return (
diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.tsx b/apps/nextjs/src/components/board/mobile/mobile-board.tsx index 58940420f..6f67e977f 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-board.tsx @@ -1,9 +1,8 @@ "use client"; import type { CSSProperties, RefObject } from "react"; -import { useMemo, useRef, useState } from "react"; +import { useMemo, useRef } 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"; @@ -64,8 +63,6 @@ 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 45e59a506..a145bc20b 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts @@ -3,7 +3,6 @@ import { describe, expect, test } from "vitest"; import { isMobileContextActionVisible, resolveMobileItemPresentation, - shouldKeepMobileWidgetActionsMounted, shouldRenderMobileWidgetActions, } from "./mobile-presentation"; @@ -43,7 +42,7 @@ describe("resolveMobileItemPresentation", () => { }); }); - test("uses a compact one-row fallback when only details are configured", () => { + test("keeps automatic footprint defaults when only details are configured", () => { expect( resolveMobileItemPresentation( { kind: "calendar", height: 7 }, @@ -53,10 +52,8 @@ describe("resolveMobileItemPresentation", () => { ), ).toMatchObject({ width: 2, - height: 1, - displayMode: "mobileSummary", + height: 3, supportsDetails: true, - usesGenericSummary: true, }); }); @@ -78,7 +75,6 @@ describe("resolveMobileItemPresentation", () => { height: 1, displayMode: "mobileSummary", supportsDetails: false, - usesGenericSummary: false, eager: true, unmountWhenOffscreen: false, }); @@ -105,42 +101,6 @@ 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 08125d5bf..41917e4a1 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts @@ -4,21 +4,14 @@ import type { WidgetContextMenuAction, WidgetMobilePresentation } from "@homarr/ export const resolveMobileItemPresentation = ( item: { kind: WidgetKind; height: number }, mobile: WidgetMobilePresentation | undefined, -) => { - 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), - }; -}; +) => ({ + 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), +}); export const shouldRenderMobileWidgetActions = ({ supportsDetails, @@ -30,24 +23,5 @@ 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 deleted file mode 100644 index 6ad42dd38..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index 61c657269..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.ts +++ /dev/null @@ -1,116 +0,0 @@ -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 312a60945..60f50c234 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, useState } from "react"; +import { useMemo, useRef } 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,40 +20,20 @@ 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, - shouldKeepMobileWidgetActionsMounted, - shouldRenderMobileWidgetActions, -} from "./mobile-presentation"; +import { isMobileContextActionVisible, 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, - detailsFromCard, - detailsOpened, - openDetails, - closeDetails, - widgetStateRef, - isNearViewport, -}: MobileWidgetActionsProps) => { +export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => { const [actionsOpened, actionsDisclosure] = useDisclosure(false); - const [isOpeningDetails, setIsOpeningDetails] = useState(false); - const [isCompletingAction, setIsCompletingAction] = useState(false); - const [actionTriggerHasFocus, setActionTriggerHasFocus] = useState(false); + const [detailsOpened, detailsDisclosure] = useDisclosure(false); const actionTriggerRef = useRef(null); const detailsDialogRef = useRef(null); - const pendingContextActionRef = useRef<(() => void) | null>(null); + const openDetailsAfterActionsRef = useRef(false); const hasNestedDialog = useHasNestedDialog(detailsOpened, detailsDialogRef); const t = useI18n(); const settings = useSettings(); @@ -79,169 +59,133 @@ export const MobileWidgetActions = ({ const visibleContextActions = contextActions.filter(isMobileContextActionVisible); const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`); const hasActions = shouldRenderMobileWidgetActions({ - supportsDetails: supportsDetails && !detailsFromCard, + supportsDetails, supportsRefresh: queryKey !== undefined, visibleContextActionCount: visibleContextActions.length, }); - const shouldKeepMounted = shouldKeepMobileWidgetActionsMounted({ - isNearViewport, - actionsOpened, - detailsOpened, - isOpeningDetails, - isCompletingAction, - actionTriggerHasFocus, - }); - if ((!hasActions && !detailsOpened) || !shouldKeepMounted) return null; + if (!hasActions) return null; return ( <> - {hasActions && ( - <> - setActionTriggerHasFocus(true)} - onBlur={() => setActionTriggerHasFocus(false)} - > - - + + + - { - if (isOpeningDetails) { - setIsOpeningDetails(false); - openDetails(); - return; - } + { + 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 && } + + - 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))", - }, - }} - > - - - - - )} + 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 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.spec.ts b/apps/nextjs/src/components/board/use-mobile-board.spec.ts index df8827ac5..35a8a95b6 100644 --- a/apps/nextjs/src/components/board/use-mobile-board.spec.ts +++ b/apps/nextjs/src/components/board/use-mobile-board.spec.ts @@ -1,10 +1,6 @@ import { describe, expect, test } from "vitest"; -import { - resolveIsAutomaticMobileBoard, - resolveMobileBoardViewport, - shouldShowMobileBoardViewportSkeleton, -} from "./use-mobile-board"; +import { resolveMobileBoardViewport } from "./use-mobile-board"; describe("resolveMobileBoardViewport", () => { test("keeps phones mobile regardless of their viewport width", () => { @@ -60,26 +56,3 @@ 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 1972206f2..7197aac22 100644 --- a/apps/nextjs/src/components/board/use-mobile-board.tsx +++ b/apps/nextjs/src/components/board/use-mobile-board.tsx @@ -5,8 +5,6 @@ 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"; @@ -72,7 +70,6 @@ export const MobileBoardViewportProvider = ({ initialDeviceClass, children, }: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => { - const { enableAutomaticMobileLayout } = useSettings(); const [hasResolvedClientWidth, setHasResolvedClientWidth] = useState(false); const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", { getInitialValueInEffect: true, @@ -90,42 +87,11 @@ export const MobileBoardViewportProvider = ({ return ( - {shouldShowMobileBoardViewportSkeleton({ - enableAutomaticMobileLayout, - isResolved: viewport.isResolved, - }) ? ( - - ) : ( - children - )} + {viewport.isResolved ? children : } ); }; export const useMobileBoardViewport = () => useContext(MobileBoardViewportContext); -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, - }); -}; +export const useIsMobileBoard = () => useMobileBoardViewport().isMobile; diff --git a/apps/nextjs/src/components/language/current-language-combobox.tsx b/apps/nextjs/src/components/language/current-language-combobox.tsx index 9a8dbcb52..54d9a0f98 100644 --- a/apps/nextjs/src/components/language/current-language-combobox.tsx +++ b/apps/nextjs/src/components/language/current-language-combobox.tsx @@ -1,6 +1,5 @@ "use client"; -import { requestBoardEditAction } from "@homarr/boards/edit-mode"; import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client"; import { LanguageCombobox } from "./language-combobox"; @@ -17,7 +16,7 @@ export const CurrentLanguageCombobox = ({ width, withinPortal }: CurrentLanguage return ( requestBoardEditAction(() => changeLocale(locale))} + onChange={changeLocale} isPending={isPending} width={width} withinPortal={withinPortal} diff --git a/e2e/mobile-board.spec.ts b/e2e/mobile-board.spec.ts index 6c929b16e..959c57065 100644 --- a/e2e/mobile-board.spec.ts +++ b/e2e/mobile-board.spec.ts @@ -1,7 +1,6 @@ -import type { Page } from "@playwright/test"; import { chromium, devices, expect } from "@playwright/test"; import { createId } from "@paralleldrive/cuid2"; -import { stringify } from "superjson"; +import SuperJSON from "superjson"; import { describe, test } from "vitest"; import { eq } from "drizzle-orm"; @@ -19,34 +18,9 @@ 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 desktopLayoutId = createId(); - const mobileLayoutId = createId(); + const layoutId = createId(); const firstSectionId = createId(); const secondSectionId = createId(); @@ -55,22 +29,13 @@ const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => { 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.layouts).values({ + id: layoutId, + name: "Desktop", + boardId, + columnCount: 12, + breakpoint: 0, + }); await db.insert(sqliteSchema.sections).values([ { id: firstSectionId, @@ -95,33 +60,22 @@ const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => { return { id, boardId, - kind: index === 0 ? ("notebook" as const) : index === 1 ? ("rssFeed" as const) : ("clock" as const), - options: stringify(index === 0 ? { content: "

Mobile notes

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

Mobile notes

" } : {}), + advancedOptions: SuperJSON.stringify({}), }; }); await db.insert(sqliteSchema.items).values(itemRows); await db.insert(sqliteSchema.itemLayouts).values( - 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, - }, - ]), + 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, + })), ); await db .update(sqliteSchema.users) @@ -158,7 +112,6 @@ 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 = [ @@ -197,7 +150,6 @@ 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")); @@ -249,10 +201,6 @@ 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(); @@ -261,20 +209,12 @@ 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", exact: true }).click(); + await page.getByRole("button", { name: "Open widget details" }).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; @@ -293,56 +233,7 @@ 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/serverSettings.ts b/packages/api/src/router/serverSettings.ts index dc117fc0c..9783e0ae9 100644 --- a/packages/api/src/router/serverSettings.ts +++ b/packages/api/src/router/serverSettings.ts @@ -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 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) @@ -104,19 +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]; - 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); - } + } 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 d3d43b213..c045b1288 100644 --- a/packages/api/src/router/test/serverSettings.spec.ts +++ b/packages/api/src/router/test/serverSettings.spec.ts @@ -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({ @@ -130,27 +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 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({ @@ -193,7 +145,6 @@ describe("board settings API", () => { await caller.updateBoardSettings({ homeBoardId: boardId }); const result = await caller.updateBoardSettings({ mobileHomeBoardId: boardId, - enableAutomaticMobileLayout: false, enableStatusByDefault: false, }); @@ -201,7 +152,6 @@ 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 efddcf136..ca032b700 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 { getIntegrationQueryProvenance, settleIntegrationQueries } from "./settle-integrations"; +import { settleIntegrationQueries } from "./settle-integrations"; const integrations = [ { id: "first", name: "First", kind: "mock" }, @@ -76,19 +76,3 @@ 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/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 a4ebfcb25..000000000 --- a/packages/boards/src/context.spec.ts +++ /dev/null @@ -1,36 +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("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 deleted file mode 100644 index 25bf237f8..000000000 --- a/packages/boards/src/edit-mode.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 ce5395b30..6ca606b99 100644 --- a/packages/boards/src/edit-mode.tsx +++ b/packages/boards/src/edit-mode.tsx @@ -6,28 +6,6 @@ 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 deleted file mode 100644 index b9139ace1..000000000 --- a/packages/db/migrations/seed.spec.ts +++ /dev/null @@ -1,106 +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.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 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 679db8752..371b18f29 100644 --- a/packages/db/queries/server-setting.ts +++ b/packages/db/queries/server-setting.ts @@ -1,4 +1,4 @@ -import { parse, stringify } from "superjson"; +import SuperJSON from "superjson"; import type { ServerSettings } from "@homarr/server-settings"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; @@ -7,71 +7,23 @@ 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(); - 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); acc[settingKey] = { ...defaultServerSettings[settingKey], - ...persistedSettings, - } as never; + ...SuperJSON.parse(setting.value), + }; 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) => { @@ -80,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 ( @@ -115,7 +46,7 @@ export const updateServerSettingByKeyAsync = async { await db.insert(serverSettings).values({ settingKey: key, - value: stringify(value), + value: SuperJSON.stringify(value), }); }; diff --git a/packages/modals/src/confirm-modal.tsx b/packages/modals/src/confirm-modal.tsx index 3cac2bfa4..efea408e7 100644 --- a/packages/modals/src/confirm-modal.tsx +++ b/packages/modals/src/confirm-modal.tsx @@ -16,7 +16,6 @@ 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 d21a59061..ef365647c 100644 --- a/packages/modals/src/index.tsx +++ b/packages/modals/src/index.tsx @@ -128,7 +128,6 @@ interface OpenModalOptions { keepMounted?: boolean; onClose?: () => void; title?: stringOrTranslation; - onClose?: () => void; } export const useModalAction = (modal: TModal) => { @@ -149,10 +148,6 @@ export const useConfirmModal = () => { const { openModal } = useModalAction(ConfirmModal); return { - openConfirmModal: (props: ConfirmModalProps) => - openModal(props, { - title: props.title, - onClose: props.onClose, - }), + openConfirmModal: (props: ConfirmModalProps) => openModal(props, { title: props.title }), }; }; 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/spotlight/package.json b/packages/spotlight/package.json index 1239f3bfb..7781ba78a 100644 --- a/packages/spotlight/package.json +++ b/packages/spotlight/package.json @@ -26,7 +26,6 @@ "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 1fe3d64b8..fd61dc26e 100644 --- a/packages/spotlight/src/preferences/use-user-preference.ts +++ b/packages/spotlight/src/preferences/use-user-preference.ts @@ -7,7 +7,6 @@ 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"; @@ -161,7 +160,7 @@ export const useUserPreferences = () => { const clientSetters: Partial void>> = { colorScheme: (value) => setColorScheme(value as ColorScheme), - locale: (value) => requestBoardEditAction(() => changeLocale(value as typeof currentLocale)), + locale: (value) => changeLocale(value as typeof currentLocale), fullPreferencesPage: () => undefined, }; diff --git a/packages/widgets/src/common/query-state.tsx b/packages/widgets/src/common/query-state.tsx deleted file mode 100644 index 2347a8b57..000000000 --- a/packages/widgets/src/common/query-state.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"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 78a932126..a1fcf531d 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, unmountWhenOffscreen: true }, + mobile: { supportsDetailView: 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 faf737a05..11d52c999 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, unmountWhenOffscreen: true }, + mobile: { supportsDetailView: 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 050b45cde..dcc77a041 100644 --- a/packages/widgets/src/dns-hole/summary/index.ts +++ b/packages/widgets/src/dns-hole/summary/index.ts @@ -10,7 +10,6 @@ export const widgetKind = "dnsHoleSummary"; export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { supportsAdvancedFocus: false, icon: IconAd, - mobile: { unmountWhenOffscreen: true }, queryKey: [["widget", "dnsHole"]], refetchInterval: 5, createOptions() {