From b612fb9a897da2690dd4ff27806be47fbe0d0ef9 Mon Sep 17 00:00:00 2001 From: ajnart Date: Wed, 29 Jul 2026 20:06:47 +0200 Subject: [PATCH] Revert "feat: complete automatic mobile board UX" This reverts commit 7959c20d88ab4b6af470459ebfdf2bb336b210a5. --- apps/docs/docs/management/api/index.mdx | 7 +- apps/docs/docs/management/boards/index.mdx | 14 +- apps/docs/docs/management/settings/index.mdx | 34 +- .../[locale]/boards/(content)/_creator.tsx | 13 +- .../boards/(content)/_header-actions.tsx | 230 ++----------- .../boards/(content)/_prefetch-policy.spec.ts | 10 +- .../boards/(content)/_prefetch-policy.ts | 8 +- .../boards/[name]/settings/_layout.tsx | 161 +--------- .../[locale]/boards/[name]/settings/page.tsx | 5 +- .../[locale]/boards/_board-switcher-logo.tsx | 13 +- apps/nextjs/src/app/[locale]/layout.tsx | 1 - .../_components/board-settings-form.tsx | 7 - .../settings/_components/common-form.tsx | 3 - .../board/items/actions/create-item.ts | 12 +- .../board/items/actions/empty-position.ts | 2 +- .../items/actions/move-and-resize-item.ts | 9 +- .../items/actions/move-item-to-section.ts | 9 +- .../items/actions/test/create-item.spec.ts | 26 +- .../items/actions/test/empty-position.spec.ts | 4 - .../actions/test/move-and-resize-item.spec.ts | 12 +- .../actions/test/move-item-to-section.spec.ts | 12 +- .../components/board/items/item-content.tsx | 119 ++----- .../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 +++++++----------- .../category/category-menu-actions.tsx | 6 +- .../board/sections/dynamic-section.tsx | 6 +- .../board/sections/gridstack/gridstack.tsx | 8 - .../board/sections/gridstack/use-gridstack.ts | 8 +- .../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 | 50 +-- e2e/mobile-board.spec.ts | 153 ++------- packages/api/src/router/board.ts | 287 ++--------------- packages/api/src/router/serverSettings.ts | 18 +- packages/api/src/router/test/board.spec.ts | 198 +----------- .../src/router/test/serverSettings.spec.ts | 50 --- .../api/src/router/widgets/media-release.ts | 75 ++--- .../api/src/router/widgets/media-requests.ts | 147 +++------ packages/api/src/settle-integrations.spec.ts | 18 +- packages/api/src/test/mcp.spec.ts | 1 - packages/boards/package.json | 1 - packages/boards/src/context.spec.ts | 36 --- packages/boards/src/context.tsx | 59 +--- 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 +- .../src/lib/request-handler.spec.ts | 50 --- .../src/lib/request-handler.ts | 25 +- 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/translation/src/lang/en.json | 21 +- packages/validation/src/board.ts | 42 +-- packages/widgets/src/bookmarks/component.tsx | 20 +- packages/widgets/src/bookmarks/index.tsx | 9 +- packages/widgets/src/calendar/component.tsx | 14 +- .../widgets/src/common/query-state.spec.ts | 34 -- 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 - packages/widgets/src/downloads/component.tsx | 54 +--- packages/widgets/src/firewall/index.ts | 2 +- .../widgets/src/media-releases/component.tsx | 26 +- .../src/media-requests/list/component.tsx | 43 +-- .../src/media-requests/stats/component.tsx | 37 +-- .../widgets/src/media-requests/stats/index.ts | 7 +- .../widgets/src/media-server/component.tsx | 22 +- .../widgets/src/notifications/component.tsx | 21 +- packages/widgets/src/tracearr/index.ts | 2 +- packages/widgets/src/umami/index.ts | 2 +- .../widgets/src/uptime-kuma/component.tsx | 20 +- pnpm-lock.yaml | 6 - 85 files changed, 565 insertions(+), 2867 deletions(-) delete mode 100644 apps/nextjs/src/components/board/mobile/mobile-viewport-observer.spec.ts delete mode 100644 apps/nextjs/src/components/board/mobile/mobile-viewport-observer.ts delete mode 100644 packages/boards/src/context.spec.ts delete mode 100644 packages/boards/src/edit-mode.spec.ts delete mode 100644 packages/db/migrations/seed.spec.ts delete mode 100644 packages/request-handler/src/lib/request-handler.spec.ts delete mode 100644 packages/widgets/src/common/query-state.spec.ts delete 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 fda610981..2c5bd42e2 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -80,9 +80,6 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio - Change board visibility - Update board visual and behavior settings - Set desktop and mobile home boards for the current user -- Configure global board defaults such as public home boards, automatic mobile layout mode, and status behavior -- Configure saved responsive layouts through Homarr's board settings UI when a server uses legacy mobile layout mode +- Configure global board defaults such as public home boards and status behavior -Use the interactive API reference to inspect the exact request body for OpenAPI endpoints under the `boards` and -`settings` tags. Saved responsive layouts are intentionally not exposed as an OpenAPI or MCP replacement operation -because omitting a layout would remove it. +Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags. diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index e004d75bd..a9e7bb187 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -84,29 +84,23 @@ Homarr will fall back to the Homarr logo if nothing is set. ### Layout -The available controls depend on the server-wide **Use automatic mobile layout** setting under `Management` > `Settings` > `Boards`. +In this section you can configure the number of columns in the desktop layout. Items and sections can be moved and resized on desktop while the board is in edit mode. Changing the column count automatically adjusts item positions and sizes to fit the new grid. -#### Automatic mobile layout +#### Mobile layout -When automatic mobile layout is enabled, the board settings contain one desktop column-count control. Phones use an automatic, read-only layout with exactly two columns in both portrait and landscape. Tablets use this layout at widths up to 48em. Items follow the visual order of the desktop layout and are limited to two columns wide and three rows high. +Phones use an automatic, read-only layout with exactly two columns in both portrait and landscape. Tablets use this layout at widths up to 48em. Items follow the visual order of the desktop layout and are limited to two columns wide and three rows high. -Named categories and dynamic sections appear as lightweight headings and section shortcuts, but never as layout containers. Their items are included directly in the automatic grid, even when a category is collapsed on desktop. Layout and item-configuration menus stay locked on mobile; links, refresh actions, automations, notebook content and checklists, and widget details remain interactive according to the user's normal permissions. +Named categories and dynamic sections appear as lightweight headings and section shortcuts, but never as layout containers. Their items are included directly in the automatic grid, even when a category is collapsed on desktop. Layout and widget configuration stay locked on mobile; links, refresh actions, automations, and widget details remain interactive. App items use compact tiles. Widgets can opt into a compact summary, and larger widgets provide a full-screen detail view. Widgets farther down a long board are loaded shortly before they enter the viewport while their space is preserved with a skeleton. To rearrange, add, remove, resize, or configure board items, open the board on a larger screen. -#### Saved responsive layouts - -When automatic mobile layout is disabled, each board uses its saved responsive layouts. Board editors can add named layouts, choose their column counts and viewport breakpoints, and edit the selected layout directly on mobile. - -Enabling automatic mode does not delete these layouts. Homarr continues to maintain item and section placements for them, so an administrator can switch the server back to responsive mode later without losing the previous mobile arrangement. - ### Background #### Background image URL diff --git a/apps/docs/docs/management/settings/index.mdx b/apps/docs/docs/management/settings/index.mdx index a727c717d..d7705e61a 100644 --- a/apps/docs/docs/management/settings/index.mdx +++ b/apps/docs/docs/management/settings/index.mdx @@ -9,12 +9,10 @@ tags: --- # Settings - The settings page has a few options that will be applied to the entire Homarr application. They may require a full page reload for changes to apply. ## Analytics - By default, Homarr is collecting anonymous analytics to identify issues and prioritize bugfixes and improvements. The data is never shared with any third-parties and is fully GDPR & CCPA compliant. The software being used is [Umami Analytics](https://umami.is/) which is fully open source. @@ -23,29 +21,25 @@ The main server software can be found [here](https://github.com/umami-software/u A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions. We understand that you might not want Homarr to communicate with our services. -By unchecking the `Send anonymous analytics` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected. -If you are paranoid, you can permanently block `umami.homarr.dev` from your DNS / firewall. +By unchecking the ``Send anonymous analytics`` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected. +If you are paranoid, you can permanently block ``umami.homarr.dev`` from your DNS / firewall. Additionally, you opt in to more data collection to help us provide more useful data. ### Integration data - Enabling this optional data will send the types of integrations (e.g. SABnzbd, Sonarr, ...) and how many you have configured of them. This will not include any URLs, names, secrets, errors and others. This is disabled by default. ### Widget data - Enabling this optional data will send the types of widgets (e.g. downloads, media streams, iframe, ...) and how many you have configured of them. This will not include any URLs, names, secrets, errors and others. This is disabled by default. ### Users data - Enabling this optional data will send the count of users and whether you activated SSO. This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs. This is disabled by default. ## Search Engine Optimization (SEO) - Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages. This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard. Homarr is requesting the crawlers to not index Homarr instances by default for this reason. @@ -53,26 +47,22 @@ If you use Homarr as a public website and want it to be searchable on these sear Please note that Homarr doesn't have control over what the crawlers do; it is a mere request to them to not perform these actions but will not guarantee that your website doesn't land on some index. ### No index - This will ask crawlers to not index your Homarr instance on any indexes. It is up to the crawler to properly respect this request. By default, this setting is enabled. Disabling it will allow indexing. ### No follow - If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website. By default, this setting is enabled and will ask crawlers to not follow any links. Crawler of different companies may behave differently when indexing is prohibited and following is allowed. ### No translate - Google and Chrome on Android will prompt users to translate pages that are not in their device language. This can lead to some shifting and can be undesired in some cases. By enabling this, you ask crawlers to never translate and never prompt users for translation. It should be noted that some browsers will ignore this and translate regardless. ### No site links search box - This setting is exclusive to Google. [Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing". The user can search them directly in Google. @@ -81,22 +71,8 @@ This setting is disabled by default. ## Boards -### Automatic mobile layout - -**Use automatic mobile layout** controls mobile rendering for every board on the server: - -- Enabled: phones and small tablets use Homarr's read-only, two-column mobile view. Mobile layout editing is hidden. -- Disabled: boards use their saved responsive layouts and users with board modification access can edit layouts on mobile. - -This setting is enabled by default for new instances. During an upgrade, Homarr keeps it disabled when it detects existing custom responsive layouts, including boards with multiple layouts or a non-default saved layout. Instances that only contain the canonical desktop layout use the new automatic mode. Once an administrator saves an explicit choice, later upgrades preserve it. - -Saved responsive layouts are retained while automatic mode is enabled, so switching between the modes is reversible. Reload any board tabs that were already open when the setting changed. - -The automatic-layout setting is independent from the global mobile board below: one controls how a board is rendered, while the other controls which board opens on a detected mobile device. - ### Global home board - -You can set a global home board that will be used for all users that have not set a home board themselves. +You can set a global home board that will be used for all users that have not set a home board themselves. It will also be used for anonymous users. Note that the board must be public to be used as a global home board. @@ -121,11 +97,9 @@ You can decide if the app status should be disabled for all boards. ## Appearance ### Default color scheme - You can set the default color scheme for all users that have not set a color scheme themselves. ## Culture ### Default language - -You can set the default language for all users that have not set a language themselves. +You can set the default language for all users that have not set a language themselves. \ No newline at end of file diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index c2ea55291..5ec14adfa 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -18,8 +18,6 @@ import { isNullOrWhitespace } from "@homarr/common"; import { userAgent } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { db } from "@homarr/db"; -import { getServerSettingByKeyAsync } from "@homarr/db/queries"; import type { WidgetKind } from "@homarr/definitions"; import { getQueryCacheAsync } from "@homarr/redis"; import { getI18n } from "@homarr/translation/server"; @@ -56,12 +54,7 @@ export const createBoardContentPage = >( const resolvedParams = await params; const queryClient = getQueryClient(); - const [board, session, requestHeaders, boardSettings] = await Promise.all([ - getInitialBoard(resolvedParams), - auth(), - headers(), - getServerSettingByKeyAsync(db, "board"), - ]); + const [board, session, requestHeaders] = await Promise.all([getInitialBoard(resolvedParams), auth(), headers()]); const deviceType = userAgent(new Headers(requestHeaders)).device.type; const itemsMap = board.items.reduce((acc, item) => { @@ -77,9 +70,7 @@ export const createBoardContentPage = >( const [integrations] = await Promise.all([ getIntegrationsWithPermissionsAsync(session), ...Array.from(itemsMap) - .filter(([kind]) => - shouldPrefetchWidgetForRequest(kind, deviceType, boardSettings.enableAutomaticMobileLayout), - ) + .filter(([kind]) => shouldPrefetchWidgetForRequest(kind, deviceType)) .map(([kind, items]) => prefetchForKindAsync(kind, queryClient, items).catch((error) => { logger.error( diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 56cf5d6e7..1c2ea43d0 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useRouter } from "next/navigation"; import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; import { Box, Button, Divider, Drawer, Group, Menu, ScrollArea, Stack, Text } from "@mantine/core"; @@ -29,8 +29,7 @@ import { import { clientApi } from "@homarr/api/client"; import { signOut, useSession } from "@homarr/auth/client"; import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context"; -import type { BoardEditAction, BoardEditActionEvent } from "@homarr/boards/edit-mode"; -import { boardEditActionEventName, requestBoardEditAction, useEditMode } from "@homarr/boards/edit-mode"; +import { useEditMode } from "@homarr/boards/edit-mode"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { env } from "@homarr/common/env"; import { hotkeys } from "@homarr/definitions"; @@ -62,8 +61,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool const isMobile = useIsMobileBoard(); const t = useI18n(); - usePreventLeaveWithDirty(isEditMode); - if (isMobile) { return ; } @@ -218,6 +215,8 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden: }, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges, hidden]); useHotkeys([[hotkeys.toggleBoardEdit, toggle]]); + usePreventLeaveWithDirty(isEditMode); + if (hidden) return null; return ( @@ -270,7 +269,6 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => { const tProfile = useScopedI18n("common.userAvatar.menu"); const reduceMotion = useReducedMotion(); const [opened, disclosure] = useDisclosure(false); - const [isSigningOut, setIsSigningOut] = useState(false); const moreTriggerRef = useRef(null); const pendingSectionAnchorId = useRef(null); const desktopLayout = getDesktopLayout(board); @@ -292,26 +290,6 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => { moreTriggerRef.current?.focus({ preventScroll: true }); }; - const handleSignOut = useCallback(async () => { - setIsSigningOut(true); - - try { - const response = await signOut({ redirect: false }); - if (typeof response.url !== "string" || response.url.length === 0) { - throw new Error("Sign out did not return a success URL"); - } - window.location.replace(logoutUrl ?? "/auth/login"); - return true; - } catch { - setIsSigningOut(false); - showErrorNotification({ - title: tProfile("logoutError.title"), - message: tProfile("logoutError.message"), - }); - return false; - } - }, [logoutUrl, tProfile]); - return ( <> @@ -413,8 +391,13 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => { size="lg" justify="flex-start" leftSection={} - loading={isSigningOut} - onClick={() => requestBoardEditAction(handleSignOut)} + onClick={() => { + void signOut({ redirect: false }) + .catch(() => undefined) + .then(() => { + window.location.assign(logoutUrl ?? "/auth/login"); + }); + }} > {tProfile("logout")} @@ -449,207 +432,62 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => { ); }; -const anchorSelector = "a[href]:not([target='_blank']):not([download])"; +const anchorSelector = "a[href]:not([target='_blank'])"; const usePreventLeaveWithDirty = (isDirty: boolean) => { const t = useI18n(); const { openConfirmModal } = useConfirmModal(); const router = useRouter(); - const dependenciesRef = useRef({ openConfirmModal, router, t }); - - useEffect(() => { - dependenciesRef.current = { openConfirmModal, router, t }; - }, [openConfirmModal, router, t]); useEffect(() => { if (!isDirty) return; - const guardStateKey = "__homarrBoardEditGuard"; - const guardId = `${Date.now()}-${Math.random()}`; - let guardedUrl = window.location.href; - let isLeaving = false; - let isConfirming = false; - let isActionPending = false; - let shouldRemoveGuardEntry = true; - let didPageHide = false; - let departureFallbackTimer: number | undefined; + const handleClick = (event: Event) => { + const target = (event.target as HTMLElement).closest("a"); - const pushGuardEntry = () => { - window.history.pushState( - { - ...(window.history.state as Record | null), - [guardStateKey]: guardId, - }, - document.title, - guardedUrl, - ); - }; - - const rearmGuard = () => { - window.clearTimeout(departureFallbackTimer); - departureFallbackTimer = undefined; - isLeaving = false; - shouldRemoveGuardEntry = true; - - if ( - window.location.href === guardedUrl && - (window.history.state as Record | null)?.[guardStateKey] !== guardId - ) { - pushGuardEntry(); + if (!target) { + console.warn("No anchor element found for click event", event); + return; } - }; - const scheduleDepartureFallback = () => { - window.clearTimeout(departureFallbackTimer); - departureFallbackTimer = window.setTimeout(() => { - departureFallbackTimer = undefined; - if (!didPageHide) { - guardedUrl = window.location.href; - rearmGuard(); - } - }, 1_000); - }; + event.preventDefault(); - const openLeaveConfirmation = (action: BoardEditAction) => { - if (isConfirming) return; - isConfirming = true; - - const dependencies = dependenciesRef.current; - dependencies.openConfirmModal({ - title: dependencies.t("board.action.edit.confirmLeave.title"), - children: dependencies.t("board.action.edit.confirmLeave.message"), - async onConfirm() { - isActionPending = true; - isLeaving = true; - shouldRemoveGuardEntry = false; - didPageHide = false; - - try { - const result = await action(); - if (result === false) { - rearmGuard(); - } else { - scheduleDepartureFallback(); - } - } catch { - rearmGuard(); - } finally { - isActionPending = false; - isConfirming = false; - } - }, - onClose() { - if (!isActionPending) { - isConfirming = false; - } + openConfirmModal({ + title: t("board.action.edit.confirmLeave.title"), + children: t("board.action.edit.confirmLeave.message"), + onConfirm() { + router.push(target.href); }, confirmProps: { - children: dependencies.t("common.action.discard"), + children: t("common.action.discard"), }, }); }; - pushGuardEntry(); - - const handleClick = (event: MouseEvent) => { - if ( - event.defaultPrevented || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey || - !(event.target instanceof Element) - ) { - return; - } - - const target = event.target.closest(anchorSelector); - if (!target) return; - + const handlePopState = (event: Event) => { + window.history.pushState(null, document.title, window.location.href); event.preventDefault(); - const destination = new URL(target.href, window.location.href); - - openLeaveConfirmation(() => { - if (destination.origin === window.location.origin) { - dependenciesRef.current.router.replace(`${destination.pathname}${destination.search}${destination.hash}`); - } else { - window.location.replace(destination.href); - } - return true; - }); - }; - - const handlePopState = () => { - if (isActionPending) { - if ( - window.location.href === guardedUrl && - (window.history.state as Record | null)?.[guardStateKey] !== guardId - ) { - pushGuardEntry(); - } - return; - } - if (isLeaving) return; - - pushGuardEntry(); - openLeaveConfirmation(() => { - window.history.go(-2); - return true; - }); - }; - - const handleBoardEditAction = (event: Event) => { - event.preventDefault(); - openLeaveConfirmation((event as BoardEditActionEvent).detail.action); - }; - - const handlePageShow = (event: PageTransitionEvent) => { - if (!event.persisted) return; - - didPageHide = false; - guardedUrl = window.location.href; - isLeaving = false; - isConfirming = false; - shouldRemoveGuardEntry = true; - if ((window.history.state as Record | null)?.[guardStateKey] !== guardId) { - pushGuardEntry(); - } - }; - - const handlePageHide = () => { - didPageHide = true; }; const handleBeforeUnload = (event: BeforeUnloadEvent) => { - if (isLeaving || env.NODE_ENV === "development") return; + if (env.NODE_ENV === "development") return; event.preventDefault(); event.returnValue = true; }; - document.addEventListener("click", handleClick, true); - document.addEventListener(boardEditActionEventName, handleBoardEditAction); + const anchors = document.querySelectorAll(anchorSelector); + anchors.forEach((link) => { + link.addEventListener("click", handleClick); + }); window.addEventListener("popstate", handlePopState); - window.addEventListener("pagehide", handlePageHide); - window.addEventListener("pageshow", handlePageShow); window.addEventListener("beforeunload", handleBeforeUnload); return () => { - document.removeEventListener("click", handleClick, true); - document.removeEventListener(boardEditActionEventName, handleBoardEditAction); + anchors.forEach((link) => { + link.removeEventListener("click", handleClick); + }); window.removeEventListener("popstate", handlePopState); - window.removeEventListener("pagehide", handlePageHide); - window.removeEventListener("pageshow", handlePageShow); window.removeEventListener("beforeunload", handleBeforeUnload); - window.clearTimeout(departureFallbackTimer); - - if ( - shouldRemoveGuardEntry && - window.location.href === guardedUrl && - (window.history.state as Record | null)?.[guardStateKey] === guardId - ) { - window.history.back(); - } }; }, [isDirty]); }; 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/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx index 668dfa3e9..ab7604fca 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -1,42 +1,19 @@ "use client"; -import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core"; +import { Button, Fieldset, Group, Input, Slider, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { getDesktopLayout } from "@homarr/boards/context"; -import { createId } from "@homarr/common"; import { useZodForm } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; -import { boardSaveLayoutSchema, boardSaveLayoutsSchema } from "@homarr/validation/board"; +import { boardSaveLayoutSchema } from "@homarr/validation/board"; import type { Board } from "../../_types"; interface Props { board: Board; - enableAutomaticMobileLayout: boolean; } - -const getNextAvailableBreakpoint = (layouts: Array<{ breakpoint: number }>) => { - const breakpoints = new Set(layouts.map((layout) => layout.breakpoint)); - const commonBreakpoints = [0, 480, 768, 1024, 1200, 1440, 1920]; - const commonBreakpoint = commonBreakpoints.find((breakpoint) => !breakpoints.has(breakpoint)); - if (commonBreakpoint !== undefined) return commonBreakpoint; - - for (let breakpoint = 0; breakpoint <= 32767; breakpoint++) { - if (!breakpoints.has(breakpoint)) return breakpoint; - } - - throw new Error("No responsive layout breakpoints are available"); -}; - -export const LayoutSettingsContent = ({ board, enableAutomaticMobileLayout }: Props) => - enableAutomaticMobileLayout ? ( - - ) : ( - - ); - -const AutomaticLayoutSettingsContent = ({ board }: Pick) => { +export const LayoutSettingsContent = ({ board }: Props) => { const t = useI18n(); const utils = clientApi.useUtils(); const desktopLayout = getDesktopLayout(board); @@ -68,20 +45,13 @@ const AutomaticLayoutSettingsContent = ({ board }: Pick) => { {t("board.setting.section.layout.desktop.description")} - + - {t("board.setting.section.layout.mobile.automaticDescription")} + {t("board.setting.section.layout.mobile.description")} @@ -93,124 +63,3 @@ const AutomaticLayoutSettingsContent = ({ board }: Pick) => { ); }; - -const ResponsiveLayoutSettingsContent = ({ board }: Pick) => { - const t = useI18n(); - const utils = clientApi.useUtils(); - const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), { - initialValues: { - layouts: board.layouts, - }, - }); - const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({ - onSuccess(layouts) { - const values = { layouts }; - form.setValues(values); - form.resetDirty(values); - }, - onSettled() { - void utils.board.getBoardByName.invalidate({ name: board.name }); - void utils.board.getHomeBoard.invalidate(); - }, - }); - - return ( -
{ - saveLayouts({ - id: board.id, - ...values, - }); - })} - > - - - {t("board.setting.section.layout.responsive.description")} - - - - {t("board.setting.section.layout.responsive.title")} - - - - {form.values.layouts.map((layout, index) => ( -
- - - - - - - - - - - - - - - - {form.values.layouts.length >= 2 && ( - - - - )} -
- ))} -
- - - - -
-
- ); -}; 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 fd699b38c..a728e8949 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx @@ -90,10 +90,7 @@ export default async function BoardSettingsPage(props: Props) { - + 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 5416f0d7c..2388e2812 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -107,7 +107,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/app/[locale]/manage/settings/_components/board-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx index a7202e911..615c6ba38 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx @@ -33,13 +33,6 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett {...form.getInputProps("mobileHomeBoardId")} /> - {tBoard("mobileLayout.title")} - - {tBoard("status.title")} ({ defaultValues: ServerSettings[TKey]; children: (form: ReturnType>) => React.ReactNode; }) => { - const router = useRouter(); const t = useI18n(); const tSettings = useScopedI18n("management.page.settings"); const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({ @@ -26,7 +24,6 @@ export const CommonSettingsForm = ({ showSuccessNotification({ message: tSettings("notification.success.message"), }); - router.refresh(); }, onError() { showErrorNotification({ diff --git a/apps/nextjs/src/components/board/items/actions/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts index 9a0cf3d41..d4b2cb24d 100644 --- a/apps/nextjs/src/components/board/items/actions/create-item.ts +++ b/apps/nextjs/src/components/board/items/actions/create-item.ts @@ -60,15 +60,9 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid return layouts.map((layoutId) => { const boardLayout = board.layouts.find((layout) => layout.id === layoutId); const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId }); - const layoutItemSize = boardLayout - ? { - ...itemSize, - width: Math.min(itemSize.width, boardLayout.columnCount), - } - : itemSize; const emptyPosition = boardLayout - ? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, layoutItemSize) + ? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize) : { xOffset: 0, yOffset: 0 }; if (!emptyPosition) { @@ -76,8 +70,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid } return { - width: layoutItemSize.width, - height: layoutItemSize.height, + width: itemSize.width, + height: itemSize.height, ...emptyPosition, sectionId: currentSection.id, layoutId, 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/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts index bdef2d101..e2a0ff93c 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 @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import * as boardContext from "@homarr/boards/context"; @@ -10,10 +10,6 @@ import { ItemMockBuilder } from "./mocks/item-mock"; import { LayoutMockBuilder } from "./mocks/layout-mock"; describe("item actions create-item", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - test("should add it to first section", () => { // Arrange const itemKind = "clock"; @@ -112,24 +108,4 @@ describe("item actions create-item", () => { { height: 1, width: 1 }, ); }); - - test("clamps wide widgets to each retained layout", () => { - const desktopLayout = new LayoutMockBuilder({ id: "desktop", columnCount: 12 }).build(); - const mobileLayout = new LayoutMockBuilder({ id: "mobile", columnCount: 1 }).build(); - const board = new BoardMockBuilder() - .addLayout(desktopLayout) - .addLayout(mobileLayout) - .addEmptySection({ id: "root", yOffset: 0 }) - .build(); - - const result = createItemCallback({ kind: "mediaMissing" })(board); - const item = result.items.at(0); - - expect(item?.layouts).toEqual( - expect.arrayContaining([ - expect.objectContaining({ layoutId: desktopLayout.id, width: 4 }), - expect.objectContaining({ layoutId: mobileLayout.id, width: 1 }), - ]), - ); - }); }); diff --git a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts index 819af19f9..fcc194618 100644 --- a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts @@ -106,10 +106,6 @@ describe("get first empty position", () => { expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined); }); - - test("does not place an item wider than the layout", () => { - expect(getFirstEmptyPosition([], 1, undefined, { width: 2, height: 1 })).toBeUndefined(); - }); }); const createElementsFromLayout = (layout: string[][]) => { 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/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index b0d169a54..37601c4ee 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,6 +1,6 @@ import type { MutableRefObject } from "react"; import { useRef } from "react"; -import { Badge, Card, Center, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core"; +import { Badge, Card } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import combineClasses from "clsx"; @@ -10,8 +10,6 @@ import { ErrorBoundary } from "react-error-boundary"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { useSettings } from "@homarr/settings"; -import { useI18n } from "@homarr/translation/client"; -import type { TablerIcon } from "@homarr/ui"; import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import type { WidgetDisplayMode } from "@homarr/widgets"; import { WidgetError } from "@homarr/widgets/errors"; @@ -29,9 +27,7 @@ interface BoardItemContentProps { displayMode?: WidgetDisplayMode; disableContextMenu?: boolean; isReadOnly?: boolean; - disableItemConfiguration?: boolean; widgetStateRef?: MutableRefObject | null>; - onOpenDetails?: () => void; } const getOverflowFromKind = (kind: SectionItem["kind"]) => { @@ -45,9 +41,7 @@ export const BoardItemContent = ({ displayMode = "default", disableContextMenu = false, isReadOnly = false, - disableItemConfiguration = false, widgetStateRef: externalWidgetStateRef, - onOpenDetails, }: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); const board = useRequiredBoard(); @@ -79,9 +73,7 @@ export const BoardItemContent = ({ height={height} displayMode={displayMode} isReadOnly={isReadOnly} - disableItemConfiguration={disableItemConfiguration} widgetStateRef={widgetStateRef} - onOpenDetails={onOpenDetails} /> ); @@ -125,23 +117,13 @@ interface InnerContentProps { height: number; displayMode: WidgetDisplayMode; isReadOnly: boolean; - disableItemConfiguration: boolean; widgetStateRef: MutableRefObject | null>; - onOpenDetails?: () => void; } -const InnerContent = ({ - item, - displayMode, - isReadOnly, - disableItemConfiguration, - onOpenDetails, - ...dimensions -}: InnerContentProps) => { +const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerContentProps) => { const settings = useSettings(); const board = useRequiredBoard(); const [isEditMode] = useEditMode(); - const t = useI18n(); const Comp = loadWidgetDynamic(item.kind); const { definition } = widgetImports[item.kind]; const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options); @@ -151,14 +133,6 @@ const InnerContent = ({ updateItemOptions({ itemId: item.id, newOptions }); const widgetSupportsIntegrations = "supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1; - const hasNativeMobileSummary = - "mobile" in definition && - definition.mobile !== undefined && - "supportsCompactSummary" in definition.mobile && - definition.mobile.supportsCompactSummary === true; - const useGenericMobileSummary = displayMode === "mobileSummary" && !hasNativeMobileSummary; - const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`); - const showItemMenu = !isReadOnly && !disableItemConfiguration; const queryClient = useQueryClient(); return ( @@ -171,7 +145,7 @@ const InnerContent = ({ }} fallbackRender={({ resetErrorBoundary, error }) => ( <> - {showItemMenu && } + {!isReadOnly && } )} @@ -184,79 +158,32 @@ const InnerContent = ({ (!("integrationsRequired" in definition) || definition.integrationsRequired !== false) } /> - {showItemMenu && } - {useGenericMobileSummary ? ( - - ) : ( - { - if (isReadOnly) return; - updateOptions({ - newOptions: { - ...options, - ...partialNewOptions.newOptions, - }, - }); - }} - displayMode={displayMode} - {...dimensions} - /> - )} + {!isReadOnly && } + { + if (isReadOnly) return; + updateOptions({ + newOptions: { + ...options, + ...partialNewOptions.newOptions, + }, + }); + }} + displayMode={displayMode} + {...dimensions} + /> )} ); }; -const GenericMobileSummary = ({ - icon: Icon, - title, - widgetName, - detailsLabel, - onOpenDetails, -}: { - icon: TablerIcon; - title: string; - widgetName: string; - detailsLabel: string; - onOpenDetails?: () => void; -}) => ( - -
- - - - - - {widgetName} - - - {detailsLabel} - - -
-
-); - const Throw = ({ when, error }: { when: boolean; error: Error }) => { if (when) throw error; return null; 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/category/category-menu-actions.tsx b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx index 7a17620ea..906abdea6 100644 --- a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx +++ b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx @@ -1,7 +1,7 @@ import { useCallback } from "react"; import { fetchApi } from "@homarr/api/client"; -import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; +import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { createId } from "@homarr/common"; import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; @@ -18,7 +18,6 @@ export const useCategoryMenuActions = (category: CategorySection) => { const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions(); const t = useI18n(); const board = useRequiredBoard(); - const currentLayoutId = useCurrentLayout(); const createCategoryAtYOffset = useCallback( (position: number) => { @@ -105,6 +104,7 @@ export const useCategoryMenuActions = (category: CategorySection) => { const settings = useSettings(); const openAllInNewTabs = useCallback(async () => { + const currentLayoutId = getCurrentLayout(board); const appIds = filterByItemKind( board.items.filter( (item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id, @@ -130,7 +130,7 @@ export const useCategoryMenuActions = (category: CategorySection) => { }); break; } - }, [category, board, currentLayoutId, t, openConfirmModal, settings]); + }, [category, board, t, openConfirmModal, settings]); return { addCategoryAbove, diff --git a/apps/nextjs/src/components/board/sections/dynamic-section.tsx b/apps/nextjs/src/components/board/sections/dynamic-section.tsx index c599f607e..8bb0a52c3 100644 --- a/apps/nextjs/src/components/board/sections/dynamic-section.tsx +++ b/apps/nextjs/src/components/board/sections/dynamic-section.tsx @@ -1,7 +1,7 @@ import { Badge, Box, Card } from "@mantine/core"; import combineClasses from "clsx"; -import { useRequiredBoard } from "@homarr/boards/context"; +import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import type { DynamicSectionItem } from "~/app/[locale]/boards/_types"; import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu"; @@ -14,6 +14,7 @@ interface Props { export const BoardDynamicSection = ({ section }: Props) => { const board = useRequiredBoard(); + const currentLayoutId = useCurrentLayout(); const options = section.options; return ( @@ -51,7 +52,8 @@ export const BoardDynamicSection = ({ section }: Props) => { {options.title} )} - + {/* Use unique key by layout to reinitialize gridstack */} + diff --git a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx index 1688f6544..e851e5c2a 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx +++ b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx @@ -4,8 +4,6 @@ import type { BoxProps } from "@mantine/core"; import { Box } from "@mantine/core"; import combineClasses from "clsx"; -import { useCurrentLayout } from "@homarr/boards/context"; - import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types"; import { SectionContent } from "../content"; import { SectionProvider } from "../section-context"; @@ -17,12 +15,6 @@ interface Props extends BoxProps { } export const GridStack = ({ section, ...props }: Props) => { - const currentLayoutId = useCurrentLayout(); - - return ; -}; - -const GridStackForLayout = ({ section, ...props }: Props) => { const { items, innerSections } = useSectionItems(section.id); const itemIds = [...items, ...innerSections].map((item) => item.id); diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts index 4b3c7d33f..b5e468de6 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts +++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts @@ -103,7 +103,6 @@ export const useGridstack = (section: Omit, itemIds: string[]) // Updates the react-query state moveAndResizeItem({ itemId: id, - layoutId: currentLayoutId, // We want the following properties to be null by default // so the next free position is used from the gridstack // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -121,7 +120,6 @@ export const useGridstack = (section: Omit, itemIds: string[]) if (type === "section") { moveAndResizeInnerSection({ innerSectionId: id, - layoutId: currentLayoutId, // We want the following properties to be null by default // so the next free position is used from the gridstack // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -138,7 +136,7 @@ export const useGridstack = (section: Omit, itemIds: string[]) console.error(`Unknown grid-stack-item type to move. type='${type}' id='${id}'`); }, - [currentLayoutId, moveAndResizeItem, moveAndResizeInnerSection], + [moveAndResizeItem, moveAndResizeInnerSection], ); const onAdd = useCallback( (addedNode: GridStackNode) => { @@ -151,7 +149,6 @@ export const useGridstack = (section: Omit, itemIds: string[]) // Updates the react-query state moveItemToSection({ itemId: id, - layoutId: currentLayoutId, sectionId: section.id, // We want the following properties to be null by default // so the next free position is used from the gridstack @@ -170,7 +167,6 @@ export const useGridstack = (section: Omit, itemIds: string[]) if (type === "section") { moveInnerSectionToSection({ innerSectionId: id, - layoutId: currentLayoutId, sectionId: section.id, // We want the following properties to be null by default // so the next free position is used from the gridstack @@ -188,7 +184,7 @@ export const useGridstack = (section: Omit, itemIds: string[]) console.error(`Unknown grid-stack-item type to add. type='${type}' id='${id}'`); }, - [currentLayoutId, moveItemToSection, moveInnerSectionToSection, section.id], + [moveItemToSection, moveInnerSectionToSection, section.id], ); // initialize the gridstack diff --git a/apps/nextjs/src/components/board/sections/section-actions.tsx b/apps/nextjs/src/components/board/sections/section-actions.tsx index 3db4a7924..f61f51552 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 !== "dynamic") 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 !== "dynamic") 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/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx index 2201a658f..cf6d013a2 100644 --- a/apps/nextjs/src/components/user-avatar-menu.tsx +++ b/apps/nextjs/src/components/user-avatar-menu.tsx @@ -17,10 +17,8 @@ import { import type { RouterOutputs } from "@homarr/api"; import { signOut, useSession } from "@homarr/auth/client"; -import { requestBoardEditAction } from "@homarr/boards/edit-mode"; import { hotkeys } from "@homarr/definitions"; import { createModal, useModalAction } from "@homarr/modals"; -import { showErrorNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; @@ -49,36 +47,22 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal); const handleSignout = useCallback(async () => { - try { - const response = await signOut({ - redirect: false, - }); - if (typeof response.url !== "string" || response.url.length === 0) { - throw new Error("Sign out did not return a success URL"); - } - - return await new Promise((resolve) => { - openModal({ - onTimeout: () => { - resolve(true); - if (logoutUrl) { - window.location.replace(logoutUrl); - return; - } - router.replace("/auth/login"); - }, - }); - }); - } catch { - showErrorNotification({ - title: t("logoutError.title"), - message: t("logoutError.message"), - }); - return false; - } - }, [logoutUrl, openModal, router, t]); + await signOut({ + redirect: false, + }); + openModal({ + onTimeout: () => { + if (logoutUrl) { + window.location.assign(logoutUrl); + return; + } + router.push("/auth/login"); + }, + }); + }, [logoutUrl, openModal, router]); return ( + // We use keepMounted so we can add event listeners to prevent navigating away without saving the board @@ -116,11 +100,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab )} {session.status === "authenticated" ? ( - requestBoardEditAction(handleSignout)} - leftSection={} - color="red" - > + } color="red"> {t("logout")} ) : ( 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/board.ts b/packages/api/src/router/board.ts index ec74a2baf..1993b8231 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -49,7 +49,6 @@ import { boardDuplicateSchema, boardRenameSchema, boardSaveLayoutSchema, - boardSaveLayoutsSchema, boardSavePartialSettingsSchema, boardSavePermissionsSchema, boardSaveSchema, @@ -688,7 +687,7 @@ export const boardRouter = createTRPCRouter({ mcp: { enabled: true, description: - "Set a board's desktop column count while preserving saved responsive layouts. Requires modify permission. REQUIRED: id (board ID), columnCount (1-24)", + "Set a board's desktop column count and remove legacy responsive layouts. Requires modify permission. REQUIRED: id (board ID), columnCount (1-24)", }, }) .input(boardSaveLayoutSchema) @@ -762,8 +761,12 @@ export const boardRouter = createTRPCRouter({ await transaction .update(schema.layouts) - .set({ columnCount: input.columnCount }) + .set({ name: "Base", columnCount: input.columnCount, breakpoint: 0 }) .where(eq(schema.layouts.id, desktopLayout.id)); + + await transaction + .delete(schema.layouts) + .where(and(eq(schema.layouts.boardId, board.id), not(eq(schema.layouts.id, desktopLayout.id)))); }); }, handleSync(db) { @@ -808,230 +811,18 @@ export const boardRouter = createTRPCRouter({ transaction .update(layouts) - .set({ columnCount: input.columnCount }) + .set({ name: "Base", columnCount: input.columnCount, breakpoint: 0 }) .where(eq(layouts.id, desktopLayout.id)) .run(); + + transaction + .delete(layouts) + .where(and(eq(layouts.boardId, board.id), not(eq(layouts.id, desktopLayout.id)))) + .run(); }); }, }); }), - saveLayouts: protectedProcedure - .input(boardSaveLayoutsSchema) - .output(boardSaveLayoutsSchema.shape.layouts) - .mutation(async ({ ctx, input }) => { - await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify"); - - const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id); - const addedLayouts = filterAddedItems(input.layouts, board.layouts); - const layoutsToInsert: InferInsertModel[] = []; - const itemSectionLayoutsToInsert: InferInsertModel[] = []; - const sectionLayoutsToInsert: InferInsertModel[] = []; - const savedLayoutIds = new Map(); - - for (const addedLayout of addedLayouts) { - const layoutId = createId(); - savedLayoutIds.set(addedLayout.id, layoutId); - - layoutsToInsert.push({ - id: layoutId, - name: addedLayout.name, - columnCount: addedLayout.columnCount, - breakpoint: addedLayout.breakpoint, - boardId: board.id, - }); - - const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount); - const layoutToClone = - sortedLayouts.find((layout) => layout.columnCount >= addedLayout.columnCount) ?? sortedLayouts.at(-1); - - if (!layoutToClone) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a layout" }); - } - - const updatedBoardLayout = getUpdatedBoardLayout(board, { - previous: { - layoutId: layoutToClone.id, - columnCount: layoutToClone.columnCount, - }, - current: { - layoutId, - columnCount: addedLayout.columnCount, - }, - }); - - itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts); - sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts); - } - - const updatedLayouts = filterUpdatedItems(input.layouts, board.layouts); - const itemSectionLayoutsToUpdate: InferInsertModel[] = []; - const sectionLayoutsToUpdate: InferInsertModel[] = []; - const layoutsToUpdate: typeof input.layouts = []; - - for (const updatedLayout of updatedLayouts) { - const dbLayout = board.layouts.find((layout) => layout.id === updatedLayout.id); - if (!dbLayout) continue; - - if (dbLayout.columnCount !== updatedLayout.columnCount) { - const updatedBoardLayout = getUpdatedBoardLayout(board, { - previous: { - layoutId: dbLayout.id, - columnCount: dbLayout.columnCount, - }, - current: { - layoutId: dbLayout.id, - columnCount: updatedLayout.columnCount, - }, - }); - - itemSectionLayoutsToUpdate.push(...updatedBoardLayout.itemSectionLayouts); - sectionLayoutsToUpdate.push(...updatedBoardLayout.sectionLayouts); - } - - layoutsToUpdate.push(updatedLayout); - } - - const removedLayoutIds = filterRemovedItems(input.layouts, board.layouts).map((layout) => layout.id); - - await handleTransactionsAsync(ctx.db, { - async handleAsync(db, schema) { - await db.transaction(async (transaction) => { - if (layoutsToInsert.length > 0) { - await transaction.insert(schema.layouts).values(layoutsToInsert); - } - if (itemSectionLayoutsToInsert.length > 0) { - await transaction.insert(schema.itemLayouts).values(itemSectionLayoutsToInsert); - } - if (sectionLayoutsToInsert.length > 0) { - await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert); - } - - for (const itemSectionLayout of itemSectionLayoutsToUpdate) { - await transaction - .update(schema.itemLayouts) - .set({ - height: itemSectionLayout.height, - width: itemSectionLayout.width, - xOffset: itemSectionLayout.xOffset, - yOffset: itemSectionLayout.yOffset, - sectionId: itemSectionLayout.sectionId, - }) - .where( - and( - eq(schema.itemLayouts.itemId, itemSectionLayout.itemId), - eq(schema.itemLayouts.layoutId, itemSectionLayout.layoutId), - ), - ); - } - - for (const sectionLayout of sectionLayoutsToUpdate) { - await transaction - .update(schema.sectionLayouts) - .set({ - height: sectionLayout.height, - width: sectionLayout.width, - xOffset: sectionLayout.xOffset, - yOffset: sectionLayout.yOffset, - parentSectionId: sectionLayout.parentSectionId, - }) - .where( - and( - eq(schema.sectionLayouts.sectionId, sectionLayout.sectionId), - eq(schema.sectionLayouts.layoutId, sectionLayout.layoutId), - ), - ); - } - - for (const layout of layoutsToUpdate) { - await transaction - .update(schema.layouts) - .set({ - name: layout.name, - columnCount: layout.columnCount, - breakpoint: layout.breakpoint, - }) - .where(eq(schema.layouts.id, layout.id)); - } - - if (removedLayoutIds.length > 0) { - await transaction.delete(schema.layouts).where(inArray(schema.layouts.id, removedLayoutIds)); - } - }); - }, - handleSync(db) { - db.transaction((transaction) => { - if (layoutsToInsert.length > 0) { - transaction.insert(layouts).values(layoutsToInsert).run(); - } - if (itemSectionLayoutsToInsert.length > 0) { - transaction.insert(itemLayouts).values(itemSectionLayoutsToInsert).run(); - } - if (sectionLayoutsToInsert.length > 0) { - transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run(); - } - - for (const itemSectionLayout of itemSectionLayoutsToUpdate) { - transaction - .update(itemLayouts) - .set({ - height: itemSectionLayout.height, - width: itemSectionLayout.width, - xOffset: itemSectionLayout.xOffset, - yOffset: itemSectionLayout.yOffset, - sectionId: itemSectionLayout.sectionId, - }) - .where( - and( - eq(itemLayouts.itemId, itemSectionLayout.itemId), - eq(itemLayouts.layoutId, itemSectionLayout.layoutId), - ), - ) - .run(); - } - - for (const sectionLayout of sectionLayoutsToUpdate) { - transaction - .update(sectionLayouts) - .set({ - height: sectionLayout.height, - width: sectionLayout.width, - xOffset: sectionLayout.xOffset, - yOffset: sectionLayout.yOffset, - parentSectionId: sectionLayout.parentSectionId, - }) - .where( - and( - eq(sectionLayouts.sectionId, sectionLayout.sectionId), - eq(sectionLayouts.layoutId, sectionLayout.layoutId), - ), - ) - .run(); - } - - for (const layout of layoutsToUpdate) { - transaction - .update(layouts) - .set({ - name: layout.name, - columnCount: layout.columnCount, - breakpoint: layout.breakpoint, - }) - .where(eq(layouts.id, layout.id)) - .run(); - } - - if (removedLayoutIds.length > 0) { - transaction.delete(layouts).where(inArray(layouts.id, removedLayoutIds)).run(); - } - }); - }, - }); - - return input.layouts.map((layout) => ({ - ...layout, - id: savedLayoutIds.get(layout.id) ?? layout.id, - })); - }), savePartialBoardSettings: protectedProcedure .meta({ openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true }, @@ -1700,16 +1491,16 @@ export const boardRouter = createTRPCRouter({ } const itemId = createId(); - const itemRow = { + + await ctx.db.insert(items).values({ id: itemId, boardId: input.boardId, kind: input.kind, options: superjson.stringify(input.options), advancedOptions: emptySuperJSON, - }; + }); const layoutRows: (typeof itemLayouts.$inferInsert)[] = []; - const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 }; for (const layout of board.layouts) { const existingInSection = board.items @@ -1728,17 +1519,14 @@ export const boardRouter = createTRPCRouter({ } } - const layoutItemSize = { - ...defaultSize, - width: Math.min(defaultSize.width, layout.columnCount), - }; + const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 }; const fitsAt = (x: number, y: number) => { - if (x + layoutItemSize.width > layout.columnCount) return false; - for (let dy = 0; dy < layoutItemSize.height; dy++) { + if (x + defaultSize.width > layout.columnCount) return false; + for (let dy = 0; dy < defaultSize.height; dy++) { const row = occupied[y + dy]; if (!row) continue; - for (let dx = 0; dx < layoutItemSize.width; dx++) { + for (let dx = 0; dx < defaultSize.width; dx++) { if (row[x + dx]) return false; } } @@ -1756,8 +1544,8 @@ export const boardRouter = createTRPCRouter({ layoutId: layout.id, xOffset: x, yOffset: y, - width: layoutItemSize.width, - height: layoutItemSize.height, + width: defaultSize.width, + height: defaultSize.height, }); placed = true; } @@ -1772,32 +1560,15 @@ export const boardRouter = createTRPCRouter({ } } - const integrationRows = input.integrationIds.map((integrationId) => ({ itemId, integrationId })); + if (layoutRows.length > 0) { + await ctx.db.insert(itemLayouts).values(layoutRows); + } - await handleTransactionsAsync(ctx.db, { - async handleAsync(db, schema) { - await db.transaction(async (transaction) => { - await transaction.insert(schema.items).values(itemRow); - if (layoutRows.length > 0) { - await transaction.insert(schema.itemLayouts).values(layoutRows); - } - if (integrationRows.length > 0) { - await transaction.insert(schema.integrationItems).values(integrationRows); - } - }); - }, - handleSync(db) { - db.transaction((transaction) => { - transaction.insert(items).values(itemRow).run(); - if (layoutRows.length > 0) { - transaction.insert(itemLayouts).values(layoutRows).run(); - } - if (integrationRows.length > 0) { - transaction.insert(integrationItems).values(integrationRows).run(); - } - }); - }, - }); + if (input.integrationIds.length > 0) { + await ctx.db + .insert(integrationItems) + .values(input.integrationIds.map((integrationId) => ({ itemId, integrationId }))); + } return { itemId }; }), 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/board.spec.ts b/packages/api/src/router/test/board.spec.ts index 8239f67c1..94a46bc31 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; import { createId } from "@homarr/common"; import type { Database, InferInsertModel } from "@homarr/db"; -import { and, eq, not } from "@homarr/db"; +import { and, eq } from "@homarr/db"; import { boardGroupPermissions, boards, @@ -1382,187 +1382,6 @@ describe("saveGroupBoardPermissions should save group board permissions", () => ); }); -const createExistingLayout = (id: string) => ({ - id, - name: "Base", - columnCount: 10, - breakpoint: 0, -}); - -const createNewLayout = (columnCount: number) => ({ - id: createId(), - name: "New layout", - columnCount, - breakpoint: 1400, -}); - -describe("saveLayouts should preserve legacy responsive layout editing", () => { - test("should add a layout and clone item and dynamic-section placements", async () => { - const db = createDb(); - const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); - const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default"); - const assignments = await createItemsAndSectionsAsync(db, { - boardId, - layoutId, - sectionId, - }); - const newLayout = createNewLayout(3); - - const savedLayouts = await caller.saveLayouts({ - id: boardId, - layouts: [createExistingLayout(layoutId), newLayout], - }); - - const layout = expectToBeDefined( - await db.query.layouts.findFirst({ - where: not(eq(layouts.id, layoutId)), - }), - ); - expect(layout).toMatchObject({ - name: newLayout.name, - columnCount: newLayout.columnCount, - breakpoint: newLayout.breakpoint, - }); - expect(savedLayouts).toEqual([createExistingLayout(layoutId), { ...newLayout, id: layout.id }]); - await expectLayoutForRootLayoutAsync(db, sectionId, layout.id, { - ...assignments.inRoot, - a: itemId, - }); - await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layout.id, assignments.inDynamicSection); - - await caller.saveLayouts({ id: boardId, layouts: savedLayouts }); - await expect(db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) })).resolves.toHaveLength(2); - }); - - test("should update layout metadata and positions", async () => { - const db = createDb(); - const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); - const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default"); - const assignments = await createItemsAndSectionsAsync(db, { - boardId, - layoutId, - sectionId, - }); - - await caller.saveLayouts({ - id: boardId, - layouts: [ - { - id: layoutId, - name: "Phone", - columnCount: 3, - breakpoint: 480, - }, - ], - }); - - await expect(db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) })).resolves.toMatchObject({ - name: "Phone", - columnCount: 3, - breakpoint: 480, - }); - await expectLayoutForRootLayoutAsync(db, sectionId, layoutId, { - ...assignments.inRoot, - a: itemId, - }); - await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection); - }); - - test("should remove layouts omitted from the saved responsive configuration", async () => { - const db = createDb(); - const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); - const { boardId, layoutId } = await createFullBoardAsync(db, "default"); - - await caller.saveLayouts({ - id: boardId, - layouts: [createNewLayout(12)], - }); - - await expect(db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) })).resolves.toBeUndefined(); - }); - - test("should reject duplicate responsive breakpoints", async () => { - const db = createDb(); - const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); - const { boardId, layoutId } = await createFullBoardAsync(db, "default"); - - await expect( - caller.saveLayouts({ - id: boardId, - layouts: [ - createExistingLayout(layoutId), - { - ...createNewLayout(4), - breakpoint: 0, - }, - ], - }), - ).rejects.toMatchObject({ code: "BAD_REQUEST" }); - }); - - test("should reject duplicate responsive layout IDs", async () => { - const db = createDb(); - const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); - const { boardId, layoutId } = await createFullBoardAsync(db, "default"); - const existingLayout = createExistingLayout(layoutId); - - await expect( - caller.saveLayouts({ - id: boardId, - layouts: [existingLayout, { ...existingLayout, name: "Phone", breakpoint: 480 }], - }), - ).rejects.toMatchObject({ code: "BAD_REQUEST" }); - }); - - test("should reject fractional responsive layout values", async () => { - const db = createDb(); - const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); - const { boardId, layoutId } = await createFullBoardAsync(db, "default"); - - await expect( - caller.saveLayouts({ - id: boardId, - layouts: [{ ...createExistingLayout(layoutId), breakpoint: 480.5 }], - }), - ).rejects.toMatchObject({ code: "BAD_REQUEST" }); - - await expect( - caller.saveLayouts({ - id: boardId, - layouts: [{ ...createExistingLayout(layoutId), columnCount: 3.5 }], - }), - ).rejects.toMatchObject({ code: "BAD_REQUEST" }); - }); -}); - -describe("addItem should preserve every saved layout", () => { - test("should clamp wide widgets to a retained narrow layout", async () => { - const db = createDb(); - const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); - const { boardId, layoutId } = await createFullBoardAsync(db, "default"); - const mobileLayoutId = createId(); - await db.insert(layouts).values({ - id: mobileLayoutId, - name: "Mobile", - columnCount: 1, - breakpoint: 480, - boardId, - }); - - const { itemId } = await caller.addItem({ - boardId, - kind: "mediaMissing", - }); - - const savedItemLayouts = await db.query.itemLayouts.findMany({ - where: eq(itemLayouts.itemId, itemId), - }); - expect(savedItemLayouts).toHaveLength(2); - expect(savedItemLayouts).toContainEqual(expect.objectContaining({ layoutId, width: 4 })); - expect(savedItemLayouts).toContainEqual(expect.objectContaining({ layoutId: mobileLayoutId, width: 1 })); - }); -}); - describe("saveLayout should save the desktop layout", () => { test("should update position of items when column count changes", async () => { // Arrange @@ -1588,7 +1407,7 @@ describe("saveLayout should save the desktop layout", () => { }); await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection); }); - test("should preserve legacy layouts and desktop metadata", async () => { + test("should keep the desktop layout and remove legacy responsive layouts", async () => { // Arrange const db = createDb(); const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); @@ -1615,18 +1434,15 @@ describe("saveLayout should save the desktop layout", () => { // Act await caller.saveLayout({ id: boardId, - columnCount: 14, + columnCount: 16, }); // Assert const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) }); - expect(savedLayouts).toHaveLength(2); - expect(savedLayouts).toContainEqual( - expect.objectContaining({ id: layoutId, name: "Base", columnCount: 10, breakpoint: 0 }), - ); - expect(savedLayouts).toContainEqual( - expect.objectContaining({ id: desktopLayoutId, name: "Large", columnCount: 14, breakpoint: 1200 }), - ); + expect(savedLayouts).toEqual([ + expect.objectContaining({ id: desktopLayoutId, name: "Base", columnCount: 16, breakpoint: 0 }), + ]); + expect(savedLayouts.some((layout) => layout.id === layoutId)).toBe(false); }); test("should fail when board not found", async () => { // Arrange 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/router/widgets/media-release.ts b/packages/api/src/router/widgets/media-release.ts index b7c80ac39..179610e31 100644 --- a/packages/api/src/router/widgets/media-release.ts +++ b/packages/api/src/router/widgets/media-release.ts @@ -2,53 +2,36 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { getIntegrationQueryProvenance, settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -const mediaReleasesProcedure = publicProcedure.concat( - createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")), -); - -type MediaReleaseIntegration = Parameters[0]; - -const getMediaReleasesWithProvenanceAsync = async (integrations: MediaReleaseIntegration[]) => { - const results = await settleIntegrationQueries( - integrations, - async (integration) => { - const innerHandler = mediaReleaseRequestHandler.handler(integration, {}); - const { data, timestamp, isStale } = await innerHandler.getDataWithProvenanceAsync(); - - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - releases: data, - isStale, - }; - }, - { throwOnAllFailure: true }, - ); - const items = results.flatMap((result) => - result.releases.map((release) => ({ - ...release, - integration: result.integration, - })), - ); - - return { - items, - ...getIntegrationQueryProvenance(integrations.length, results), - }; -}; - export const mediaReleaseRouter = createTRPCRouter({ - getMediaReleases: mediaReleasesProcedure.query(async ({ ctx }) => { - return (await getMediaReleasesWithProvenanceAsync(ctx.integrations)).items; - }), - getMediaReleasesWithProvenance: mediaReleasesProcedure.query(async ({ ctx }) => { - return await getMediaReleasesWithProvenanceAsync(ctx.integrations); - }), + getMediaReleases: publicProcedure + .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease"))) + .query(async ({ ctx }) => { + const results = await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const innerHandler = mediaReleaseRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); + + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + releases: data, + }; + }, + { throwOnAllFailure: true }, + ); + return results.flatMap((result) => + result.releases.map((release) => ({ + ...release, + integration: result.integration, + })), + ); + }), }); diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index 88d9b7698..700b1d007 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -3,7 +3,6 @@ import { z } from "zod/v4"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import { mediaRequestStatusConfiguration } from "@homarr/integrations/types"; -import type { MediaRequestListInput } from "@homarr/request-handler/media-request-list"; import { mediaRequestListInputSchema, mediaRequestListRequestHandler, @@ -11,93 +10,11 @@ import { import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; -import { getIntegrationQueryProvenance, settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; -const latestRequestsProcedure = publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) - .input(mediaRequestListInputSchema); -const requestStatsProcedure = publicProcedure.concat( - createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")), -); - -type MediaRequestIntegration = Parameters[0]; -type MediaRequestStatsIntegration = Parameters[0]; - -const getLatestRequestsWithProvenanceAsync = async ( - integrations: MediaRequestIntegration[], - input: MediaRequestListInput, -) => { - const results = await settleIntegrationQueries( - integrations, - async (integration) => { - const { data, isStale } = await mediaRequestListRequestHandler - .handler(integration, input) - .getDataWithProvenanceAsync(); - return { - integration: { id: integration.id, name: integration.name, kind: integration.kind }, - data, - isStale, - }; - }, - { throwOnAllFailure: true }, - ); - const items = results - .flatMap(({ data, integration }) => - data.map((request) => ({ - ...request, - integrationId: integration.id, - })), - ) - .toSorted((dataA, dataB) => { - if (dataA.status === dataB.status) { - return dataB.createdAt.getTime() - dataA.createdAt.getTime(); - } - - return ( - mediaRequestStatusConfiguration[dataA.status].position - mediaRequestStatusConfiguration[dataB.status].position - ); - }); - - return { - items, - ...getIntegrationQueryProvenance(integrations.length, results), - }; -}; - -const getRequestStatsWithProvenanceAsync = async (integrations: MediaRequestStatsIntegration[]) => { - const results = await settleIntegrationQueries( - integrations, - async (integration) => { - const { data, isStale } = await mediaRequestStatsRequestHandler - .handler(integration, {}) - .getDataWithProvenanceAsync(); - return { - integration: { id: integration.id, name: integration.name, kind: integration.kind }, - data, - isStale, - }; - }, - { throwOnAllFailure: true }, - ); - - return { - stats: results.flatMap((result) => result.data.stats), - users: results - .flatMap((result) => - result.data.users.map((user) => ({ - ...user, - integration: result.integration, - })), - ) - .toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA), - integrations: results.map((result) => result.integration), - ...getIntegrationQueryProvenance(integrations.length, results), - }; -}; - export const mediaRequestsRouter = createTRPCRouter({ - getLatestRequests: latestRequestsProcedure + getLatestRequests: publicProcedure .meta({ mcp: { enabled: true, @@ -105,13 +22,36 @@ export const mediaRequestsRouter = createTRPCRouter({ "Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).", }, }) + .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) + .input(mediaRequestListInputSchema) .query(async ({ ctx, input }) => { - return (await getLatestRequestsWithProvenanceAsync(ctx.integrations, input)).items; + const results = await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync(); + return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; + }, + { throwOnAllFailure: true }, + ); + return results + .flatMap(({ data, integration }) => + data.map((request) => ({ + ...request, + integrationId: integration.id, + })), + ) + .toSorted((dataA, dataB) => { + if (dataA.status === dataB.status) { + return dataB.createdAt.getTime() - dataA.createdAt.getTime(); + } + + return ( + mediaRequestStatusConfiguration[dataA.status].position - + mediaRequestStatusConfiguration[dataB.status].position + ); + }); }), - getLatestRequestsWithProvenance: latestRequestsProcedure.query(async ({ ctx, input }) => { - return await getLatestRequestsWithProvenanceAsync(ctx.integrations, input); - }), - getStats: requestStatsProcedure + getStats: publicProcedure .meta({ mcp: { enabled: true, @@ -119,17 +59,30 @@ export const mediaRequestsRouter = createTRPCRouter({ "Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)", }, }) + .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .query(async ({ ctx }) => { - const result = await getRequestStatsWithProvenanceAsync(ctx.integrations); + const results = await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync(); + return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; + }, + { throwOnAllFailure: true }, + ); return { - stats: result.stats, - users: result.users, - integrations: result.integrations, + stats: results.flatMap((result) => result.data.stats), + users: results + .map((result) => + result.data.users.map((user) => ({ + ...user, + integration: result.integration, + })), + ) + .flat() + .toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA), + integrations: results.map((result) => result.integration), }; }), - getStatsWithProvenance: requestStatsProcedure.query(async ({ ctx }) => { - return await getRequestStatsWithProvenanceAsync(ctx.integrations); - }), answerRequest: protectedProcedure .meta({ mcp: { 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/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts index 94da929ac..e39e0e4ab 100644 --- a/packages/api/src/test/mcp.spec.ts +++ b/packages/api/src/test/mcp.spec.ts @@ -33,7 +33,6 @@ test("MCP tools should contain expected procedures", () => { expect(toolNames).toContain("app_byId"); expect(toolNames).toContain("app_create"); expect(toolNames).toContain("board_savePartialBoardSettings"); - expect(toolNames).toContain("board_saveLayout"); expect(toolNames).toContain("board_duplicateBoard"); expect(toolNames).toContain("info_getInfo"); expect(toolNames).toContain("invite_getAll"); diff --git a/packages/boards/package.json b/packages/boards/package.json index 353803c0e..b9fd9a40d 100644 --- a/packages/boards/package.json +++ b/packages/boards/package.json @@ -24,7 +24,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/context.tsx b/packages/boards/src/context.tsx index fea125673..b76ea03a6 100644 --- a/packages/boards/src/context.tsx +++ b/packages/boards/src/context.tsx @@ -1,13 +1,12 @@ "use client"; import type { PropsWithChildren } from "react"; -import { createContext, useCallback, useContext, useEffect, useState } from "react"; +import { createContext, useContext, useEffect } from "react"; import { usePathname } from "next/navigation"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { getActiveQueryCacheBoardId, setActiveQueryCacheBoardId } from "@homarr/api/query-cache"; -import { useSettings } from "@homarr/settings"; import { updateBoardName } from "./updater"; @@ -74,6 +73,10 @@ export const useOptionalBoard = () => { return context?.board ?? null; }; +export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]) => { + return getDesktopLayout(board).id; +}; + export const getDesktopLayout = (board: RouterOutputs["board"]["getBoardByName"]) => { const layout = board.layouts .toSorted( @@ -88,57 +91,9 @@ export const getDesktopLayout = (board: RouterOutputs["board"]["getBoardByName"] return layout; }; -export const getCurrentLayout = ( - board: RouterOutputs["board"]["getBoardByName"], - enableAutomaticMobileLayout: boolean, - viewportWidth?: number, -) => { - if (enableAutomaticMobileLayout || viewportWidth === undefined) { - return getDesktopLayout(board).id; - } +export const useCurrentLayout = () => getCurrentLayout(useRequiredBoard()); - const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint); - const layout = sortedLayouts.find((candidate) => candidate.breakpoint <= viewportWidth) ?? sortedLayouts.at(-1); - - if (!layout) { - throw new Error("Board must have a layout"); - } - - return layout.id; -}; - -export const useCurrentLayout = () => { - const board = useRequiredBoard(); - const { enableAutomaticMobileLayout } = useSettings(); - const [currentLayout, setCurrentLayout] = useState(() => - getCurrentLayout( - board, - enableAutomaticMobileLayout, - !enableAutomaticMobileLayout && typeof window !== "undefined" ? window.innerWidth : undefined, - ), - ); - - const updateCurrentLayout = useCallback(() => { - setCurrentLayout(getCurrentLayout(board, enableAutomaticMobileLayout, window.innerWidth)); - }, [board, enableAutomaticMobileLayout]); - - useEffect(() => { - updateCurrentLayout(); - - if (enableAutomaticMobileLayout) return; - - window.addEventListener("resize", updateCurrentLayout); - - return () => { - window.removeEventListener("resize", updateCurrentLayout); - }; - }, [enableAutomaticMobileLayout, updateCurrentLayout]); - - return currentLayout; -}; - -export const getBoardLayouts = (board: RouterOutputs["board"]["getBoardByName"]) => - board.layouts.map((layout) => layout.id); +export const getBoardLayouts = (board: RouterOutputs["board"]["getBoardByName"]) => [getCurrentLayout(board)]; export const useLayouts = () => { const board = useRequiredBoard(); 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 a7d11d52b..6ec906cec 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"; @@ -246,33 +245,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 28b0e72b0..9f3f2b4ac 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 dcf1cf51a..4a5596070 100644 --- a/packages/modals/src/index.tsx +++ b/packages/modals/src/index.tsx @@ -127,7 +127,6 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => { interface OpenModalOptions { keepMounted?: boolean; title?: stringOrTranslation; - onClose?: () => void; } export const useModalAction = (modal: TModal) => { @@ -148,10 +147,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/request-handler/src/lib/request-handler.spec.ts b/packages/request-handler/src/lib/request-handler.spec.ts deleted file mode 100644 index 7141572ba..000000000 --- a/packages/request-handler/src/lib/request-handler.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -// @vitest-environment node - -import { describe, expect, test, vi } from "vitest"; - -import { createRequestHandler } from "./request-handler"; - -describe("createRequestHandler", () => { - test("keeps the standard result contract free of cache provenance", async () => { - const requestHandler = createRequestHandler({ - requestAsync: vi.fn().mockResolvedValue("current"), - }); - - const result = await requestHandler.handler({ integrationId: "first" }).getDataAsync(); - - expect(result).toMatchObject({ data: "current", timestamp: expect.any(Date) }); - expect(result).not.toHaveProperty("isStale"); - }); - - test("marks an expired cache fallback as stale while preserving its timestamp", async () => { - const requestAsync = vi.fn().mockResolvedValueOnce("cached").mockRejectedValueOnce(new Error("offline")); - const requestHandler = createRequestHandler({ - requestAsync, - cacheTtlMs: 0, - fallbackToStaleOnError: true, - }); - const handler = requestHandler.handler({ integrationId: "first" }); - - const fresh = await handler.getDataWithProvenanceAsync(); - const fallback = await handler.getDataWithProvenanceAsync(); - - expect(fresh).toMatchObject({ data: "cached", isStale: false }); - expect(fallback).toEqual({ - data: "cached", - timestamp: fresh.timestamp, - isStale: true, - }); - expect(requestAsync).toHaveBeenCalledTimes(2); - }); - - test("does not hide an initial error when no cache exists", async () => { - const requestHandler = createRequestHandler({ - requestAsync: vi.fn().mockRejectedValue(new Error("offline")), - fallbackToStaleOnError: true, - }); - - await expect(requestHandler.handler({ integrationId: "first" }).getDataWithProvenanceAsync()).rejects.toThrow( - "offline", - ); - }); -}); diff --git a/packages/request-handler/src/lib/request-handler.ts b/packages/request-handler/src/lib/request-handler.ts index d45c4371d..4dbdb5b11 100644 --- a/packages/request-handler/src/lib/request-handler.ts +++ b/packages/request-handler/src/lib/request-handler.ts @@ -5,7 +5,6 @@ interface Options> { } type CacheEntry = { data: TData; timestamp: Date; expiresAt: number }; -type RequestResult = Pick, "data" | "timestamp"> & { isStale: boolean }; const MAX_CACHE_SIZE = 1000; const DEFAULT_TTL_MS = 10_000; @@ -21,21 +20,21 @@ export const createRequestHandler = , ) => { const cache = new Map>(); - const inflight = new Map>>(); + const inflight = new Map>>(); return { invalidateCache: () => { cache.clear(); inflight.clear(); }, - handler: (input: TInput) => { - const getDataWithProvenanceAsync = async (): Promise> => { + handler: (input: TInput) => ({ + async getDataAsync(): Promise<{ data: TData; timestamp: Date }> { const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS; const key = JSON.stringify(input); const cached = cache.get(key); if (cached && Date.now() < cached.expiresAt) { - return { data: cached.data, timestamp: cached.timestamp, isStale: false }; + return { data: cached.data, timestamp: cached.timestamp }; } const existing = inflight.get(key); @@ -52,27 +51,19 @@ export const createRequestHandler = = { data, timestamp: new Date(), expiresAt: Date.now() + ttl }; cache.set(key, entry); inflight.delete(key); - return { data: entry.data, timestamp: entry.timestamp, isStale: false }; + return entry; }) .catch((err) => { inflight.delete(key); if (options.fallbackToStaleOnError && cached) { - return { data: cached.data, timestamp: cached.timestamp, isStale: true }; + return cached; } throw err; }); inflight.set(key, promise); return promise; - }; - - return { - async getDataAsync(): Promise, "data" | "timestamp">> { - const { data, timestamp } = await getDataWithProvenanceAsync(); - return { data, timestamp }; - }, - getDataWithProvenanceAsync, - }; - }, + }, + }), }; }; 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 59a28f725..b9c16aef8 100644 --- a/packages/spotlight/package.json +++ b/packages/spotlight/package.json @@ -24,7 +24,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/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 9b86b67bb..5a66c98a6 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1495,10 +1495,6 @@ "docker": "Docker", "preferences": "Your preferences", "logout": "Logout", - "logoutError": { - "title": "Logout failed", - "message": "Your session is still active. Please try again." - }, "login": "Login", "homeBoard": "Your home board", "loggedOut": "Logged out", @@ -4413,15 +4409,7 @@ "description": "Configure the layout used on desktop and larger screens." }, "mobile": { - "automaticDescription": "On mobile, items are automatically arranged in two columns and cannot be moved or resized. Named categories and dynamic sections appear as headings, never as layout containers." - }, - "responsive": { - "title": "Responsive layouts", - "description": "Automatic mobile layout is disabled for this server. Configure the saved layouts selected at each viewport breakpoint; these layouts can also be edited directly on mobile.", - "defaultName": "New layout", - "action": { - "add": "Add layout" - } + "description": "On mobile, items are automatically arranged in two columns and cannot be moved or resized. Named categories and dynamic sections appear as headings, never as layout containers." } }, "background": { @@ -4871,13 +4859,6 @@ "mobileLabel": "Global mobile board", "description": "Only public boards are available for selection" }, - "mobileLayout": { - "title": "Mobile layout", - "enable": { - "label": "Use automatic mobile layout", - "description": "Arrange every board into a read-only two-column layout on phones and small tablets. Turn this off to use saved responsive layouts and mobile layout editing. Saved responsive layouts are kept when this is enabled." - } - }, "status": { "title": "App status", "enableStatusByDefault": { diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts index c68085bf0..e16b4eb11 100644 --- a/packages/validation/src/board.ts +++ b/packages/validation/src/board.ts @@ -24,7 +24,7 @@ export const boardNameSchema = z .min(1) .max(255) .regex(/^[A-Za-z0-9-_]*$/); -export const boardColumnCountSchema = z.number().int().min(1).max(24); +export const boardColumnCountSchema = z.number().min(1).max(24); export const boardByNameSchema = z.object({ name: boardNameSchema, @@ -75,46 +75,6 @@ export const boardSaveLayoutSchema = z.object({ columnCount: boardColumnCountSchema, }); -const responsiveBoardLayoutsSchema = z - .array( - z.object({ - id: z.string(), - name: z.string().trim().nonempty().max(32), - columnCount: boardColumnCountSchema, - breakpoint: z.number().int().min(0).max(32767), - }), - ) - .min(1) - .superRefine((layouts, context) => { - const seenBreakpoints = new Set(); - const seenIds = new Set(); - - layouts.forEach((layout, index) => { - if (seenIds.has(layout.id)) { - context.addIssue({ - code: "custom", - message: "Each responsive layout must have a unique ID", - path: [index, "id"], - }); - } - seenIds.add(layout.id); - - if (seenBreakpoints.has(layout.breakpoint)) { - context.addIssue({ - code: "custom", - message: "Each responsive layout must have a unique breakpoint", - path: [index, "breakpoint"], - }); - } - seenBreakpoints.add(layout.breakpoint); - }); - }); - -export const boardSaveLayoutsSchema = z.object({ - id: z.string(), - layouts: responsiveBoardLayoutsSchema, -}); - export const boardSaveSchema = z.object({ id: z.string(), sections: z.array(sectionSchema), diff --git a/packages/widgets/src/bookmarks/component.tsx b/packages/widgets/src/bookmarks/component.tsx index 7a628649f..e6b7eb7c0 100644 --- a/packages/widgets/src/bookmarks/component.tsx +++ b/packages/widgets/src/bookmarks/component.tsx @@ -7,16 +7,13 @@ import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; -import { useI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; -import { WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; import classes from "./bookmark.module.css"; -export default function BookmarksWidget({ options, itemId, displayMode }: WidgetComponentProps<"bookmarks">) { +export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) { const board = useRequiredBoard(); - const t = useI18n(); const { data = [] } = clientApi.app.byIds.useQuery(options.items, { select(data) { return data.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id)); @@ -44,21 +41,6 @@ export default function BookmarksWidget({ options, itemId, displayMode }: Widget [data], ); - if (displayMode === "mobileSummary") { - return ( - app.name) - .slice(0, 2) - .join(" · ") || t("board.mobile.details") - } - /> - ); - } - return ( {options.title.length > 0 && ( diff --git a/packages/widgets/src/bookmarks/index.tsx b/packages/widgets/src/bookmarks/index.tsx index 5234eb8fc..685983859 100644 --- a/packages/widgets/src/bookmarks/index.tsx +++ b/packages/widgets/src/bookmarks/index.tsx @@ -10,13 +10,8 @@ import { BookmarkAddButton } from "./add-button"; export const { definition, componentLoader } = createWidgetDefinition("bookmarks", { icon: IconBookmark, - mobile: { - width: 2, - height: 1, - supportsCompactSummary: true, - supportsDetailView: true, - }, - queryKey: [["app", "byIds"]], + mobile: { supportsDetailView: true }, + queryKey: [["app"]], createOptions() { return optionsBuilder.from((factory) => ({ title: factory.text(), diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index ae9ea51b9..ccb07e888 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -14,7 +14,6 @@ import { useSettings } from "@homarr/settings"; import { useScopedI18n } from "@homarr/translation/client"; import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; -import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state"; import type { WidgetComponentProps } from "../definition"; import { CalendarDay } from "./calender-day"; import classes from "./component.module.css"; @@ -45,8 +44,8 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d const { data, error, isPending } = clientApi.widget.calendar.findAllEvents.useQuery(input); const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]); - if (isPending) return ; - throwOnInitialQueryError(error, data !== undefined); + if (displayMode === "mobileSummary" && isPending) return ; + if (displayMode === "mobileSummary" && error && !data) throw error; return ( ); }; @@ -130,7 +125,7 @@ const CalendarBase = ({ ); } - const calendar = ( + return ( setMonth(new Date(month))} @@ -210,7 +205,6 @@ const CalendarBase = ({ }} /> ); - return {calendar}; }; /** diff --git a/packages/widgets/src/common/query-state.spec.ts b/packages/widgets/src/common/query-state.spec.ts deleted file mode 100644 index e724b3e66..000000000 --- a/packages/widgets/src/common/query-state.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { hasWidgetDataWarning, throwOnInitialQueryError } from "./query-state"; - -describe("widget query state", () => { - test.each([ - { error: new Error("refresh failed") }, - { failedIntegrationCount: 1 }, - { staleIntegrationCount: 1 }, - { expectedIntegrationCount: 2, receivedIntegrationCount: 1 }, - ])("warns when data provenance is incomplete or stale", (provenance) => { - expect(hasWidgetDataWarning(provenance)).toBe(true); - }); - - test("does not warn for complete current data", () => { - expect( - hasWidgetDataWarning({ - failedIntegrationCount: 0, - staleIntegrationCount: 0, - expectedIntegrationCount: 2, - receivedIntegrationCount: 2, - }), - ).toBe(false); - }); - - test("throws an initial query error", () => { - const error = new Error("initial request failed"); - expect(() => throwOnInitialQueryError(error, false)).toThrow(error); - }); - - test("keeps cached data visible after a refresh error", () => { - expect(() => throwOnInitialQueryError(new Error("refresh failed"), true)).not.toThrow(); - }); -}); 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 6608080b5..90ee7bef7 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 9639e6c78..553116564 100644 --- a/packages/widgets/src/dns-hole/controls/index.ts +++ b/packages/widgets/src/dns-hole/controls/index.ts @@ -9,7 +9,7 @@ export const widgetKind = "dnsHoleControls"; export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { 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 1e6f813f9..0d605e8e9 100644 --- a/packages/widgets/src/dns-hole/summary/index.ts +++ b/packages/widgets/src/dns-hole/summary/index.ts @@ -9,7 +9,6 @@ export const widgetKind = "dnsHoleSummary"; export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { icon: IconAd, - mobile: { unmountWhenOffscreen: true }, queryKey: [["widget", "dnsHole"]], refetchInterval: 5, createOptions() { diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index b8005d1d2..e03c937bf 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -51,8 +51,6 @@ import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/i import { showErrorNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; -import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; -import { hasWidgetDataWarning, throwOnInitialQueryError } from "../common/query-state"; import type { WidgetComponentProps } from "../definition"; dayjs.extend(relativeTime); @@ -280,57 +278,7 @@ function SpeedCell({ ); } -export default function DownloadsWidget(props: WidgetComponentProps<"downloads">) { - if (props.displayMode === "mobileSummary") { - return ; - } - - return ; -} - -function DownloadsMobileSummary({ - integrationIds, - options, -}: Pick, "integrationIds" | "options">) { - const t = useScopedI18n("widget.downloads"); - const { data, error, isPending } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({ - integrationIds, - limitPerIntegration: options.limitPerIntegration, - }); - - if (isPending) return ; - throwOnInitialQueryError(error, data !== undefined); - - const currentItems = data ?? []; - const selectedClients = currentItems.filter(({ integration }) => integrationIds.includes(integration.id)); - const visibleItemCount = selectedClients.reduce( - (total, { data: clientData }) => - total + - clientData.items - .filter(({ category }) => matchesCategoryFilter(category, options.categoryFilter, options.filterIsWhitelist)) - .filter((item) => showCompletedItem(item, options)).length, - 0, - ); - const totalDownloadSpeed = selectedClients.reduce( - (total, { data: clientData }) => total + (clientData.status.rates.down ?? 0), - 0, - ); - - return ( - - ); -} - -function DownloadClientsWidget({ +export default function DownloadClientsWidget({ isEditMode, integrationIds, options, diff --git a/packages/widgets/src/firewall/index.ts b/packages/widgets/src/firewall/index.ts index 44244f07c..da05f89df 100644 --- a/packages/widgets/src/firewall/index.ts +++ b/packages/widgets/src/firewall/index.ts @@ -7,7 +7,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("firewall", { icon: IconWall, - mobile: { supportsDetailView: true, unmountWhenOffscreen: true }, + mobile: { supportsDetailView: true }, queryKey: [["widget", "firewall"]], refetchInterval: 5, createOptions() { diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx index 79f6ed516..948f7f987 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -16,7 +16,6 @@ import type { TablerIcon } from "@homarr/ui"; import { OverflowBadge } from "@homarr/ui"; import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; -import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state"; import type { WidgetComponentProps } from "../definition"; export default function MediaReleasesWidget({ @@ -24,33 +23,29 @@ export default function MediaReleasesWidget({ integrationIds, displayMode, }: WidgetComponentProps<"mediaReleases">) { - const { data, error, isPending } = clientApi.widget.mediaRelease.getMediaReleasesWithProvenance.useQuery({ + const { data, error, isPending } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds, }); const t = useI18n("widget.mediaReleases"); - if (isPending) return ; - throwOnInitialQueryError(error, data !== undefined); - - const releases = data?.items ?? []; - const hasWarning = hasWidgetDataWarning({ - error, - failedIntegrationCount: data?.failedIntegrationCount, - staleIntegrationCount: data?.staleIntegrationCount, - }); - if (displayMode === "mobileSummary") { + if (isPending) return ; + if (error && !data) throw error; + + const releases = data ?? []; return ( ); } - const content = ( + const releases = data ?? []; + + return ( {releases.map((item, index) => ( @@ -60,11 +55,10 @@ export default function MediaReleasesWidget({ ))} ); - return {content}; } interface ItemProps { - item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleasesWithProvenance"]["items"][number]; + item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number]; options: WidgetComponentProps<"mediaReleases">["options"]; } diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index 1ee3dafd0..6ec502dec 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -27,7 +27,6 @@ import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../../common/empty-state"; import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary"; -import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../../common/query-state"; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationDataError } from "../../errors/no-data-integration"; import classes from "../search-button.module.css"; @@ -40,7 +39,11 @@ export default function MediaServerWidget({ displayMode, }: WidgetComponentProps<"mediaRequests-requestList">) { const t = useScopedI18n("widget.mediaRequests-requestList"); - const { data, error, isPending } = clientApi.widget.mediaRequests.getLatestRequestsWithProvenance.useQuery({ + const { + data: mediaRequests, + error, + isPending, + } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({ integrationIds, statuses: options.statusFilter.length > 0 @@ -49,38 +52,23 @@ export default function MediaServerWidget({ recentDays: options.recentDays, }); - if (isPending) return ; - throwOnInitialQueryError(error, data !== undefined); - if (!data) return ; - - const hasWarning = hasWidgetDataWarning({ - error, - failedIntegrationCount: data.failedIntegrationCount, - staleIntegrationCount: data.staleIntegrationCount, - }); - - if (data.items.length === 0 && !hasWarning) throw new NoIntegrationDataError(); + if (displayMode === "mobileSummary" && isPending) return ; + if (displayMode === "mobileSummary" && error && !mediaRequests) throw error; + if (!mediaRequests) return ; + if (mediaRequests.length === 0) throw new NoIntegrationDataError(); if (displayMode === "mobileSummary") { return ( ); } - if (data.items.length === 0) { - return ( - - - - ); - } - - const content = ( + return ( {!isEditMode && } - {data.items.map((mediaRequest) => ( + {mediaRequests.map((mediaRequest) => ( ); - return {content}; } const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => { @@ -123,7 +110,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }; interface MediaRequestCardProps { - request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequestsWithProvenance"]["items"][number]; + request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number]; isTiny: boolean; options: WidgetComponentProps<"mediaRequests-requestList">["options"]; } diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx index 49f586f74..5348b845a 100644 --- a/packages/widgets/src/media-requests/stats/component.tsx +++ b/packages/widgets/src/media-requests/stats/component.tsx @@ -22,8 +22,6 @@ import { openMediaRequestSearch } from "@homarr/spotlight"; import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../../common/empty-state"; -import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary"; -import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../../common/query-state"; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationDataError } from "../../errors/no-data-integration"; import classes from "./component.module.css"; @@ -36,30 +34,16 @@ export default function MediaServerWidget({ integrationIds, isEditMode, width, - displayMode, }: WidgetComponentProps<"mediaRequests-requestStats">) { const t = useScopedI18n("widget.mediaRequests-requestStats"); - const { - data: requestStats, - error, - isPending, - } = clientApi.widget.mediaRequests.getStatsWithProvenance.useQuery({ + const { data: requestStats } = clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds, }); const board = useRequiredBoard(); - if (isPending) return ; - throwOnInitialQueryError(error, requestStats !== undefined); if (!requestStats) return ; - const hasWarning = hasWidgetDataWarning({ - error, - failedIntegrationCount: requestStats.failedIntegrationCount, - staleIntegrationCount: requestStats.staleIntegrationCount, - }); - if (requestStats.users.length === 0 && requestStats.stats.length === 0 && !hasWarning) { - throw new NoIntegrationDataError(); - } + if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError(); const data = [ { @@ -104,23 +88,9 @@ export default function MediaServerWidget({ }, ] satisfies { name: keyof RequestStats; icon: Icon; number: number }[]; - if (displayMode === "mobileSummary") { - const total = data.find((stat) => stat.name === "total")?.number ?? 0; - const pending = data.find((stat) => stat.name === "pending")?.number ?? 0; - - return ( - - ); - } - const isTiny = width < 256; - const content = ( + return ( {!isEditMode && } ); - return {content}; } const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => { diff --git a/packages/widgets/src/media-requests/stats/index.ts b/packages/widgets/src/media-requests/stats/index.ts index 991465d54..d215eabac 100644 --- a/packages/widgets/src/media-requests/stats/index.ts +++ b/packages/widgets/src/media-requests/stats/index.ts @@ -7,12 +7,7 @@ import { createWidgetDefinition } from "../../definition"; export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", { icon: IconChartBar, - mobile: { - width: 2, - height: 1, - supportsCompactSummary: true, - supportsDetailView: true, - }, + mobile: { supportsDetailView: true }, queryKey: [["widget", "mediaRequests"]], createOptions() { return {}; diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index ad723d650..361b8ab7b 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -25,7 +25,6 @@ import type { TablerIcon } from "@homarr/ui"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; -import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state"; import type { WidgetComponentProps } from "../definition"; export default function MediaServerWidget({ displayMode, ...props }: WidgetComponentProps<"mediaServer">) { @@ -44,9 +43,10 @@ const MediaServerMobileSummary = ({ }); if (isPending) return ; - throwOnInitialQueryError(error, data !== undefined); + if (error && !data) throw error; const currentStreams = data ?? []; + const hasPartialFailure = currentStreams.length < integrationIds.length; const playingSessions = currentStreams .flatMap((stream) => stream.sessions) .filter((session) => session.currentlyPlaying !== null); @@ -56,11 +56,7 @@ const MediaServerMobileSummary = ({ value={playingSessions.length} label={t("items.currentlyPlaying")} description={playingSessions[0]?.currentlyPlaying?.name} - isStale={hasWidgetDataWarning({ - error, - expectedIntegrationCount: integrationIds.length, - receivedIntegrationCount: currentStreams.length, - })} + isStale={Boolean(error) || hasPartialFailure} /> ); }; @@ -69,11 +65,10 @@ const MediaServerTable = ({ options, integrationIds, }: Pick, "options" | "integrationIds">) => { - const { data: queryData, error } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({ + const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({ integrationIds, showOnlyPlaying: options.showOnlyPlaying, }); - const currentStreams = queryData ?? []; const t = useScopedI18n("widget.mediaServer"); const columns = useMemo[]>( @@ -297,14 +292,8 @@ const MediaServerTable = ({ integrationName: session?.integrationName, }; }); - throwOnInitialQueryError(error, queryData !== undefined); - const hasWarning = hasWidgetDataWarning({ - error, - expectedIntegrationCount: integrationIds.length, - receivedIntegrationCount: currentStreams.length, - }); - const content = ( + return ( ); - return {content}; }; const ItemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => { diff --git a/packages/widgets/src/notifications/component.tsx b/packages/widgets/src/notifications/component.tsx index 0ffc3cc1b..ea8cbd466 100644 --- a/packages/widgets/src/notifications/component.tsx +++ b/packages/widgets/src/notifications/component.tsx @@ -10,7 +10,6 @@ import { useTimeAgo } from "@homarr/common"; import { useScopedI18n } from "@homarr/translation/client"; import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; -import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state"; import type { WidgetComponentProps } from "../definition"; export default function NotificationsWidget({ @@ -39,20 +38,17 @@ export default function NotificationsWidget({ [notificationIntegrations], ); - if (isPending) return ; - throwOnInitialQueryError(error, notificationIntegrations !== undefined); - const hasWarning = hasWidgetDataWarning({ - error, - expectedIntegrationCount: integrationIds.length, - receivedIntegrationCount: notificationIntegrations?.length, - }); - if (displayMode === "mobileSummary") { + if (isPending) return ; + if (error && !notificationIntegrations) throw error; + + const notifications = notificationIntegrations ?? []; const latestNotification = sortedNotifications[0]; + const isStale = Boolean(error) || notifications.length < integrationIds.length; const summary = latestNotification ? ( - + ) : ( - + ); if (!latestNotification?.href) return summary; @@ -72,7 +68,7 @@ export default function NotificationsWidget({ ); } - const content = ( + return ( {sortedNotifications.length > 0 ? ( @@ -121,7 +117,6 @@ export default function NotificationsWidget({ ); - return {content}; } const NotificationMobileSummary = ({ diff --git a/packages/widgets/src/tracearr/index.ts b/packages/widgets/src/tracearr/index.ts index c6142c3d5..c3bb857ce 100644 --- a/packages/widgets/src/tracearr/index.ts +++ b/packages/widgets/src/tracearr/index.ts @@ -5,7 +5,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("tracearr", { icon: IconActivityHeartbeat, - mobile: { supportsDetailView: true, unmountWhenOffscreen: true }, + mobile: { supportsDetailView: true }, queryKey: [["widget", "tracearr"]], refetchInterval: 5, createOptions() { diff --git a/packages/widgets/src/umami/index.ts b/packages/widgets/src/umami/index.ts index 3bc4a9a43..974a3088f 100644 --- a/packages/widgets/src/umami/index.ts +++ b/packages/widgets/src/umami/index.ts @@ -8,7 +8,7 @@ export const timeFrameValues = ["today", "24h", "7d", "30d", "month", "lastMonth export const { definition, componentLoader } = createWidgetDefinition("umami", { icon: IconChartBar, - mobile: { supportsDetailView: true, unmountWhenOffscreen: true }, + mobile: { supportsDetailView: true }, queryKey: [["widget", "umami"]], refetchInterval: null, supportedIntegrations: ["umami"], diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx index 01dd62f49..78b66ef11 100644 --- a/packages/widgets/src/uptime-kuma/component.tsx +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -11,7 +11,6 @@ import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; -import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state"; import type { WidgetComponentProps } from "../definition"; import { NoIntegrationDataError } from "../errors/no-data-integration"; import classes from "./component.module.css"; @@ -118,15 +117,11 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti } = clientApi.widget.uptimeKuma.getDashboard.useQuery({ integrationIds, }); + const hasPartialFailure = (dashboardData?.length ?? 0) < integrationIds.length; - if (isPending) return ; - throwOnInitialQueryError(error, dashboardData !== undefined); + if (displayMode === "mobileSummary" && isPending) return ; + if (displayMode === "mobileSummary" && error && !dashboardData) throw error; if (!dashboardData) return ; - const hasWarning = hasWidgetDataWarning({ - error, - expectedIntegrationCount: integrationIds.length, - receivedIntegrationCount: dashboardData.length, - }); const combined = dashboardData.reduce( (acc, item) => ({ @@ -168,13 +163,13 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti value={formatNumber(combined[primaryStat], 0)} label={t(`stats.${primaryStat}`)} description={supportingDescription} - isStale={hasWarning} + isStale={Boolean(error) || hasPartialFailure} /> ); } if (!hasContent) { - return ; + return ; } return ( @@ -182,7 +177,7 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti value={`${formatNumber(uptimeValue, 1)}%`} label={t("averageUptime")} description={supportingDescription} - isStale={hasWarning} + isStale={Boolean(error) || hasPartialFailure} /> ); } @@ -191,7 +186,7 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats]; const heroRingClass = heroVariantByRing[String(options.showUptimeRing) as keyof typeof heroVariantByRing]; - const content = ( + return (
{showHero && (
@@ -246,7 +241,6 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti )}
); - return {content}; } function clampPercent(value: number): number { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9dca5084..f7fafc619 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1427,9 +1427,6 @@ importers: '@homarr/api': specifier: workspace:^0.1.0 version: link:../api - '@homarr/settings': - specifier: workspace:^0.1.0 - version: link:../settings react: specifier: 'catalog:' version: 19.2.7 @@ -2331,9 +2328,6 @@ 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