Revert "feat: complete automatic mobile board UX"
This reverts commit 7959c20d88.
This commit is contained in:
1 parent
dba9a250ff
commit
b612fb9a89
85 files changed
+565
-2867
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
<img src={layoutLargeImage} height={256} />
|
||||
|
||||
#### 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
|
||||
|
||||
@@ -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.
|
||||
@@ -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 = <TParams extends Record<string, unknown>>(
|
||||
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 = <TParams extends Record<string, unknown>>(
|
||||
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(
|
||||
|
||||
@@ -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 <MobileMoreMenu showSettings={hasChangeAccess && !demoReadOnly} />;
|
||||
}
|
||||
@@ -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<HTMLButtonElement>(null);
|
||||
const pendingSectionAnchorId = useRef<string | null>(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 (
|
||||
<>
|
||||
<HeaderButton ref={moreTriggerRef} onClick={disclosure.open} aria-label={t("board.mobile.more")}>
|
||||
@@ -413,8 +391,13 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => {
|
||||
size="lg"
|
||||
justify="flex-start"
|
||||
leftSection={<IconLogout size={20} />}
|
||||
loading={isSigningOut}
|
||||
onClick={() => requestBoardEditAction(handleSignOut)}
|
||||
onClick={() => {
|
||||
void signOut({ redirect: false })
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
window.location.assign(logoutUrl ?? "/auth/login");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tProfile("logout")}
|
||||
</Button>
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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<HTMLAnchorElement>(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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null)?.[guardStateKey] === guardId
|
||||
) {
|
||||
window.history.back();
|
||||
}
|
||||
};
|
||||
}, [isDirty]);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 ? (
|
||||
<AutomaticLayoutSettingsContent board={board} />
|
||||
) : (
|
||||
<ResponsiveLayoutSettingsContent board={board} />
|
||||
);
|
||||
|
||||
const AutomaticLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
|
||||
export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const utils = clientApi.useUtils();
|
||||
const desktopLayout = getDesktopLayout(board);
|
||||
@@ -68,20 +45,13 @@ const AutomaticLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
|
||||
{t("board.setting.section.layout.desktop.description")}
|
||||
</Text>
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider
|
||||
mt="xs"
|
||||
min={1}
|
||||
max={24}
|
||||
step={1}
|
||||
thumbLabel={t("layout.field.columnCount.label")}
|
||||
{...form.getInputProps("columnCount")}
|
||||
/>
|
||||
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps("columnCount")} />
|
||||
</Input.Wrapper>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("board.setting.section.layout.mobile.automaticDescription")}
|
||||
{t("board.setting.section.layout.mobile.description")}
|
||||
</Text>
|
||||
|
||||
<Group justify="end">
|
||||
@@ -93,124 +63,3 @@ const AutomaticLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const ResponsiveLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
|
||||
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 (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
saveLayouts({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("board.setting.section.layout.responsive.description")}
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues({
|
||||
layouts: [
|
||||
...form.values.layouts,
|
||||
{
|
||||
id: createId(),
|
||||
name: t("board.setting.section.layout.responsive.defaultName"),
|
||||
columnCount: 10,
|
||||
breakpoint: getNextAvailableBreakpoint(form.values.layouts),
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("board.setting.section.layout.responsive.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{form.values.layouts.map((layout, index) => (
|
||||
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider
|
||||
mt="xs"
|
||||
min={1}
|
||||
max={24}
|
||||
step={1}
|
||||
thumbLabel={t("layout.field.columnCount.label")}
|
||||
{...form.getInputProps(`layouts.${index}.columnCount`)}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||
label={t("layout.field.breakpoint.label")}
|
||||
description={t("layout.field.breakpoint.description")}
|
||||
min={0}
|
||||
max={32767}
|
||||
allowDecimal={false}
|
||||
allowNegative={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{form.values.layouts.length >= 2 && (
|
||||
<Group justify="end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues((previous) => {
|
||||
const previousLayouts = previous.layouts ?? [];
|
||||
return previousLayouts.length >= 2
|
||||
? {
|
||||
layouts: previousLayouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
|
||||
}
|
||||
: previous;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Fieldset>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -90,10 +90,7 @@ export default async function BoardSettingsPage(props: Props) {
|
||||
<GeneralSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="layout" icon={IconLayout}>
|
||||
<LayoutSettingsContent
|
||||
board={board}
|
||||
enableAutomaticMobileLayout={boardSettings.enableAutomaticMobileLayout}
|
||||
/>
|
||||
<LayoutSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="background" icon={IconPhoto}>
|
||||
<BackgroundSettingsContent board={board} />
|
||||
|
||||
@@ -48,22 +48,13 @@ export const BoardSwitcherLogo = () => {
|
||||
<ScrollArea.Autosize mah={320}>
|
||||
{boards.map((availableBoard) => {
|
||||
const isCurrent = availableBoard.id === board.id;
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<Menu.Item key={availableBoard.id} leftSection={<IconCheck size={20} />} aria-current="page" disabled>
|
||||
<Text truncate title={availableBoard.name}>
|
||||
{availableBoard.name}
|
||||
</Text>
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
key={availableBoard.id}
|
||||
component={Link}
|
||||
href={`/boards/${availableBoard.name}`}
|
||||
leftSection={<IconLayoutBoard size={20} />}
|
||||
leftSection={isCurrent ? <IconCheck size={20} /> : <IconLayoutBoard size={20} />}
|
||||
aria-current={isCurrent ? "page" : undefined}
|
||||
>
|
||||
<Text truncate title={availableBoard.name}>
|
||||
{availableBoard.name}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -33,13 +33,6 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
<Text fw={500}>{tBoard("mobileLayout.title")}</Text>
|
||||
<Switch
|
||||
{...form.getInputProps("enableAutomaticMobileLayout", { type: "checkbox" })}
|
||||
label={tBoard("mobileLayout.enable.label")}
|
||||
description={tBoard("mobileLayout.enable.description")}
|
||||
/>
|
||||
|
||||
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||
<Switch
|
||||
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
@@ -18,7 +17,6 @@ export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
|
||||
defaultValues: ServerSettings[TKey];
|
||||
children: (form: ReturnType<typeof useForm<ServerSettings[TKey]>>) => 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 = <TKey extends keyof ServerSettings>({
|
||||
showSuccessNotification({
|
||||
message: tSettings("notification.success.message"),
|
||||
});
|
||||
router.refresh();
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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[][]) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Record<string, unknown> | 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<HTMLDivElement>();
|
||||
const board = useRequiredBoard();
|
||||
@@ -79,9 +73,7 @@ export const BoardItemContent = ({
|
||||
height={height}
|
||||
displayMode={displayMode}
|
||||
isReadOnly={isReadOnly}
|
||||
disableItemConfiguration={disableItemConfiguration}
|
||||
widgetStateRef={widgetStateRef}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
@@ -125,23 +117,13 @@ interface InnerContentProps {
|
||||
height: number;
|
||||
displayMode: WidgetDisplayMode;
|
||||
isReadOnly: boolean;
|
||||
disableItemConfiguration: boolean;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | 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 && <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />}
|
||||
{!isReadOnly && <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />}
|
||||
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</>
|
||||
)}
|
||||
@@ -184,79 +158,32 @@ const InnerContent = ({
|
||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||
}
|
||||
/>
|
||||
{showItemMenu && <BoardItemMenu offset={4} item={newItem} />}
|
||||
{useGenericMobileSummary ? (
|
||||
<GenericMobileSummary
|
||||
icon={definition.icon}
|
||||
title={title}
|
||||
widgetName={t(`widget.${item.kind}.name`)}
|
||||
detailsLabel={t("board.mobile.details")}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
) : (
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
isReadOnly={isReadOnly}
|
||||
setOptions={(partialNewOptions) => {
|
||||
if (isReadOnly) return;
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
});
|
||||
}}
|
||||
displayMode={displayMode}
|
||||
{...dimensions}
|
||||
/>
|
||||
)}
|
||||
{!isReadOnly && <BoardItemMenu offset={4} item={newItem} />}
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
isReadOnly={isReadOnly}
|
||||
setOptions={(partialNewOptions) => {
|
||||
if (isReadOnly) return;
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
});
|
||||
}}
|
||||
displayMode={displayMode}
|
||||
{...dimensions}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
const GenericMobileSummary = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
widgetName,
|
||||
detailsLabel,
|
||||
onOpenDetails,
|
||||
}: {
|
||||
icon: TablerIcon;
|
||||
title: string;
|
||||
widgetName: string;
|
||||
detailsLabel: string;
|
||||
onOpenDetails?: () => void;
|
||||
}) => (
|
||||
<UnstyledButton
|
||||
h="100%"
|
||||
w="100%"
|
||||
p="sm"
|
||||
onClick={onOpenDetails}
|
||||
aria-label={`${detailsLabel}: ${title}`}
|
||||
data-mobile-generic-summary
|
||||
>
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap={4} maw="100%">
|
||||
<ThemeIcon variant="light" size={36} radius="xl">
|
||||
<Icon size={20} aria-hidden />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="sm" ta="center" lineClamp={1} w="100%">
|
||||
{widgetName}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs" ta="center" lineClamp={1} w="100%">
|
||||
{detailsLabel}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
const Throw = ({ when, error }: { when: boolean; error: Error }) => {
|
||||
if (when) throw error;
|
||||
return null;
|
||||
|
||||
@@ -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<HTMLDivElement | null>(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 (
|
||||
<div ref={wrapperRef} className={classes.deferredItem} aria-busy={!hasMounted}>
|
||||
|
||||
@@ -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<Record<string, unknown> | null>(null);
|
||||
const presentation = getMobilePresentation(item);
|
||||
const [isNearViewport, setIsNearViewport] = useState(presentation.eager);
|
||||
const [detailsOpened, detailsDisclosure] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -74,30 +71,16 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => {
|
||||
data-mobile-board-item={item.id}
|
||||
data-mobile-display-mode={presentation.displayMode}
|
||||
>
|
||||
<DeferredMobileItem
|
||||
eager={presentation.eager}
|
||||
unmountWhenOffscreen={presentation.unmountWhenOffscreen}
|
||||
onNearViewportChange={setIsNearViewport}
|
||||
>
|
||||
<DeferredMobileItem eager={presentation.eager} unmountWhenOffscreen={presentation.unmountWhenOffscreen}>
|
||||
<BoardItemContent
|
||||
item={item}
|
||||
displayMode={presentation.displayMode}
|
||||
disableContextMenu
|
||||
isReadOnly
|
||||
widgetStateRef={widgetStateRef}
|
||||
onOpenDetails={presentation.usesGenericSummary ? detailsDisclosure.open : undefined}
|
||||
/>
|
||||
</DeferredMobileItem>
|
||||
<MobileWidgetActions
|
||||
item={item}
|
||||
supportsDetails={presentation.supportsDetails}
|
||||
detailsFromCard={presentation.usesGenericSummary}
|
||||
detailsOpened={detailsOpened}
|
||||
openDetails={detailsDisclosure.open}
|
||||
closeDetails={detailsDisclosure.close}
|
||||
widgetStateRef={widgetStateRef}
|
||||
isNearViewport={isNearViewport}
|
||||
/>
|
||||
<MobileWidgetActions item={item} supportsDetails={presentation.supportsDetails} widgetStateRef={widgetStateRef} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
@@ -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<Element>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<Element, Set<MobileViewportSubscription>>();
|
||||
|
||||
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);
|
||||
};
|
||||
};
|
||||
@@ -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<Record<string, unknown> | 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<HTMLButtonElement | null>(null);
|
||||
const detailsDialogRef = useRef<HTMLDivElement | null>(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 && (
|
||||
<>
|
||||
<ActionIcon
|
||||
ref={actionTriggerRef}
|
||||
className={classes.widgetAction}
|
||||
variant="filled"
|
||||
color="dark"
|
||||
radius="xl"
|
||||
size={44}
|
||||
aria-label={t("board.mobile.actions", { title })}
|
||||
onClick={actionsDisclosure.open}
|
||||
onFocus={() => setActionTriggerHasFocus(true)}
|
||||
onBlur={() => setActionTriggerHasFocus(false)}
|
||||
>
|
||||
<IconDotsVertical size={20} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
ref={actionTriggerRef}
|
||||
className={classes.widgetAction}
|
||||
variant="filled"
|
||||
color="dark"
|
||||
radius="xl"
|
||||
size={44}
|
||||
aria-label={t("board.mobile.actions", { title })}
|
||||
onClick={actionsDisclosure.open}
|
||||
>
|
||||
<IconDotsVertical size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
<Drawer
|
||||
opened={actionsOpened}
|
||||
onClose={actionsDisclosure.close}
|
||||
title={title}
|
||||
position="bottom"
|
||||
size="auto"
|
||||
padding="md"
|
||||
overlayProps={{ backgroundOpacity: 0.45, blur: 2 }}
|
||||
returnFocus={false}
|
||||
styles={{
|
||||
content: {
|
||||
paddingLeft: "env(safe-area-inset-left)",
|
||||
paddingRight: "env(safe-area-inset-right)",
|
||||
},
|
||||
}}
|
||||
onExitTransitionEnd={() => {
|
||||
if (isOpeningDetails) {
|
||||
setIsOpeningDetails(false);
|
||||
openDetails();
|
||||
return;
|
||||
}
|
||||
<Drawer
|
||||
opened={actionsOpened}
|
||||
onClose={actionsDisclosure.close}
|
||||
title={title}
|
||||
position="bottom"
|
||||
size="auto"
|
||||
padding="md"
|
||||
overlayProps={{ backgroundOpacity: 0.45, blur: 2 }}
|
||||
styles={{
|
||||
content: {
|
||||
paddingLeft: "env(safe-area-inset-left)",
|
||||
paddingRight: "env(safe-area-inset-right)",
|
||||
},
|
||||
}}
|
||||
onExitTransitionEnd={() => {
|
||||
if (!openDetailsAfterActionsRef.current) return;
|
||||
openDetailsAfterActionsRef.current = false;
|
||||
detailsDisclosure.open();
|
||||
}}
|
||||
closeButtonProps={{
|
||||
"aria-label": t("common.action.close"),
|
||||
size: 44,
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs" pb="calc(var(--mantine-spacing-sm) + env(safe-area-inset-bottom))">
|
||||
{supportsDetails && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
justify="flex-start"
|
||||
size="lg"
|
||||
leftSection={<IconMaximize size={20} />}
|
||||
onClick={() => {
|
||||
openDetailsAfterActionsRef.current = true;
|
||||
actionsDisclosure.close();
|
||||
}}
|
||||
>
|
||||
{t("board.mobile.details")}
|
||||
</Button>
|
||||
)}
|
||||
{visibleContextActions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Button
|
||||
key={action.key}
|
||||
variant="subtle"
|
||||
color={action.color}
|
||||
justify="flex-start"
|
||||
size="lg"
|
||||
disabled={action.disabled}
|
||||
leftSection={Icon ? <Icon size={20} /> : undefined}
|
||||
onClick={() => {
|
||||
action.onClick();
|
||||
actionsDisclosure.close();
|
||||
}}
|
||||
>
|
||||
{String(translateIfNecessary(t, action.label))}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{queryKey && <MobileWidgetRefreshAction queryKey={queryKey} onComplete={actionsDisclosure.close} />}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs" pb="calc(var(--mantine-spacing-sm) + env(safe-area-inset-bottom))">
|
||||
{supportsDetails && !detailsFromCard && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
justify="flex-start"
|
||||
size="lg"
|
||||
leftSection={<IconMaximize size={20} />}
|
||||
onClick={() => {
|
||||
setIsOpeningDetails(true);
|
||||
actionsDisclosure.close();
|
||||
}}
|
||||
>
|
||||
{t("board.mobile.details")}
|
||||
</Button>
|
||||
)}
|
||||
{visibleContextActions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Button
|
||||
key={action.key}
|
||||
variant="subtle"
|
||||
color={action.color}
|
||||
justify="flex-start"
|
||||
size="lg"
|
||||
disabled={action.disabled}
|
||||
leftSection={Icon ? <Icon size={20} /> : undefined}
|
||||
onClick={() => {
|
||||
pendingContextActionRef.current = action.onClick;
|
||||
setIsCompletingAction(true);
|
||||
actionsDisclosure.close();
|
||||
}}
|
||||
>
|
||||
{String(translateIfNecessary(t, action.label))}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{queryKey && <MobileWidgetRefreshAction queryKey={queryKey} onComplete={actionsDisclosure.close} />}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
</>
|
||||
)}
|
||||
|
||||
{supportsDetails && (
|
||||
<Modal
|
||||
ref={detailsDialogRef}
|
||||
opened={detailsOpened}
|
||||
onClose={closeDetails}
|
||||
title={title}
|
||||
fullScreen
|
||||
padding="md"
|
||||
returnFocus={detailsFromCard}
|
||||
trapFocus={!hasNestedDialog}
|
||||
closeOnEscape={!hasNestedDialog}
|
||||
aria-hidden={hasNestedDialog || undefined}
|
||||
inert={hasNestedDialog || undefined}
|
||||
onExitTransitionEnd={() => {
|
||||
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))",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box className={classes.detailContent}>
|
||||
<BoardItemContent
|
||||
item={item}
|
||||
displayMode="mobileDetail"
|
||||
disableContextMenu
|
||||
disableItemConfiguration
|
||||
widgetStateRef={widgetStateRef}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
)}
|
||||
<Modal
|
||||
ref={detailsDialogRef}
|
||||
opened={detailsOpened}
|
||||
onClose={detailsDisclosure.close}
|
||||
title={title}
|
||||
fullScreen
|
||||
padding="md"
|
||||
returnFocus={false}
|
||||
trapFocus={!hasNestedDialog}
|
||||
closeOnEscape={!hasNestedDialog}
|
||||
onExitTransitionEnd={() => 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))",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box className={classes.detailContent}>
|
||||
<BoardItemContent
|
||||
item={item}
|
||||
displayMode="mobileDetail"
|
||||
disableContextMenu
|
||||
isReadOnly
|
||||
widgetStateRef={widgetStateRef}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
</Badge>
|
||||
)}
|
||||
<GridStack section={section} className="min-row" />
|
||||
{/* Use unique key by layout to reinitialize gridstack */}
|
||||
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
|
||||
</Card>
|
||||
<BoardDynamicSectionMenu section={section} />
|
||||
</Box>
|
||||
|
||||
@@ -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 <GridStackForLayout key={currentLayoutId} section={section} {...props} />;
|
||||
};
|
||||
|
||||
const GridStackForLayout = ({ section, ...props }: Props) => {
|
||||
const { items, innerSections } = useSectionItems(section.id);
|
||||
const itemIds = [...items, ...innerSections].map((item) => item.id);
|
||||
|
||||
|
||||
@@ -103,7 +103,6 @@ export const useGridstack = (section: Omit<Section, "items">, 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<Section, "items">, 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<Section, "items">, 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<Section, "items">, 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<Section, "items">, 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<Section, "items">, 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<MobileBoardViewportContext.Provider value={viewport}>
|
||||
{shouldShowMobileBoardViewportSkeleton({
|
||||
enableAutomaticMobileLayout,
|
||||
isResolved: viewport.isResolved,
|
||||
}) ? (
|
||||
<MobileBoardViewportSkeleton />
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{viewport.isResolved ? children : <MobileBoardViewportSkeleton />}
|
||||
</MobileBoardViewportContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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 (
|
||||
<LanguageCombobox
|
||||
value={currentLocale}
|
||||
onChange={(locale) => requestBoardEditAction(() => changeLocale(locale))}
|
||||
onChange={changeLocale}
|
||||
isPending={isPending}
|
||||
width={width}
|
||||
withinPortal={withinPortal}
|
||||
|
||||
@@ -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<boolean>((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
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
|
||||
@@ -116,11 +100,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
|
||||
)}
|
||||
<Menu.Divider />
|
||||
{session.status === "authenticated" ? (
|
||||
<Menu.Item
|
||||
onClick={() => requestBoardEditAction(handleSignout)}
|
||||
leftSection={<IconLogout size="1rem" />}
|
||||
color="red"
|
||||
>
|
||||
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
|
||||
{t("logout")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
|
||||
+22
-131
@@ -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: "<p>Mobile notes</p>" } : {}),
|
||||
advancedOptions: stringify({}),
|
||||
kind: index === 0 ? ("notebook" as const) : ("clock" as const),
|
||||
options: SuperJSON.stringify(index === 0 ? { content: "<p>Mobile notes</p>" } : {}),
|
||||
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<HTMLElement>("[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();
|
||||
|
||||
@@ -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<typeof layouts>[] = [];
|
||||
const itemSectionLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = [];
|
||||
const savedLayoutIds = new Map<string, string>();
|
||||
|
||||
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<typeof itemLayouts>[] = [];
|
||||
const sectionLayoutsToUpdate: InferInsertModel<typeof sectionLayouts>[] = [];
|
||||
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 };
|
||||
}),
|
||||
|
||||
@@ -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<ServerSettings["board"]>;
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof mediaReleaseRequestHandler.handler>[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,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -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<typeof mediaRequestListRequestHandler.handler>[0];
|
||||
type MediaRequestStatsIntegration = Parameters<typeof mediaRequestStatsRequestHandler.handler>[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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/settings": "workspace:^0.1.0",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -6,28 +6,6 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
|
||||
const EditModeContext = createContext<ReturnType<typeof useDisclosure> | null>(null);
|
||||
|
||||
export const boardEditActionEventName = "homarr:board-edit-action";
|
||||
|
||||
export type BoardEditActionResult = boolean | void;
|
||||
export type BoardEditAction = () => BoardEditActionResult | Promise<BoardEditActionResult>;
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, number>();
|
||||
|
||||
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<Record<string, unknown>>(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<Partial<ServerSettings["board"]>>(persistedBoardSettings.value)
|
||||
: {};
|
||||
|
||||
if (typeof parsedBoardSettings.enableAutomaticMobileLayout !== "boolean") {
|
||||
mergedSettings.board.enableAutomaticMobileLayout = await getAutomaticMobileLayoutUpgradeDefaultAsync(db);
|
||||
}
|
||||
|
||||
return mergedSettings;
|
||||
};
|
||||
|
||||
export const getServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(db: Database, key: TKey) => {
|
||||
@@ -80,31 +32,10 @@ export const getServerSettingByKeyAsync = async <TKey extends keyof ServerSettin
|
||||
});
|
||||
|
||||
if (!dbSettings) {
|
||||
if (key === "board") {
|
||||
return {
|
||||
...defaultServerSettings.board,
|
||||
enableAutomaticMobileLayout: await getAutomaticMobileLayoutUpgradeDefaultAsync(db),
|
||||
} as ServerSettings[TKey];
|
||||
}
|
||||
|
||||
return defaultServerSettings[key];
|
||||
}
|
||||
|
||||
const persistedSettings = parse<Partial<ServerSettings[TKey]>>(dbSettings.value);
|
||||
const mergedSettings = {
|
||||
...defaultServerSettings[key],
|
||||
...persistedSettings,
|
||||
} as ServerSettings[TKey];
|
||||
|
||||
if (
|
||||
key === "board" &&
|
||||
typeof (persistedSettings as Partial<ServerSettings["board"]>).enableAutomaticMobileLayout !== "boolean"
|
||||
) {
|
||||
(mergedSettings as ServerSettings["board"]).enableAutomaticMobileLayout =
|
||||
await getAutomaticMobileLayoutUpgradeDefaultAsync(db);
|
||||
}
|
||||
|
||||
return mergedSettings;
|
||||
return SuperJSON.parse<ServerSettings[TKey]>(dbSettings.value);
|
||||
};
|
||||
|
||||
export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
|
||||
@@ -115,7 +46,7 @@ export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSet
|
||||
await db
|
||||
.update(serverSettings)
|
||||
.set({
|
||||
value: stringify(value),
|
||||
value: SuperJSON.stringify(value),
|
||||
})
|
||||
.where(eq(serverSettings.settingKey, key));
|
||||
};
|
||||
@@ -127,6 +58,6 @@ export const insertServerSettingByKeyAsync = async <TKey extends keyof ServerSet
|
||||
) => {
|
||||
await db.insert(serverSettings).values({
|
||||
settingKey: key,
|
||||
value: stringify(value),
|
||||
value: SuperJSON.stringify(value),
|
||||
});
|
||||
};
|
||||
@@ -16,7 +16,6 @@ export interface ConfirmModalProps {
|
||||
children: ReactNode;
|
||||
onConfirm?: () => MaybePromise<void>;
|
||||
onCancel?: () => MaybePromise<void>;
|
||||
onClose?: () => void;
|
||||
closeOnConfirm?: boolean;
|
||||
closeOnCancel?: boolean;
|
||||
cancelProps?: ButtonProps & ComponentPropsWithoutRef<"button">;
|
||||
|
||||
@@ -127,7 +127,6 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
|
||||
interface OpenModalOptions {
|
||||
keepMounted?: boolean;
|
||||
title?: stringOrTranslation;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const useModalAction = <TModal extends ModalDefinition>(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 }),
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
}
|
||||
|
||||
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
|
||||
type RequestResult<TData> = Pick<CacheEntry<TData>, "data" | "timestamp"> & { isStale: boolean };
|
||||
|
||||
const MAX_CACHE_SIZE = 1000;
|
||||
const DEFAULT_TTL_MS = 10_000;
|
||||
@@ -21,21 +20,21 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
const cache = new Map<string, CacheEntry<TData>>();
|
||||
const inflight = new Map<string, Promise<RequestResult<TData>>>();
|
||||
const inflight = new Map<string, Promise<CacheEntry<TData>>>();
|
||||
|
||||
return {
|
||||
invalidateCache: () => {
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
},
|
||||
handler: (input: TInput) => {
|
||||
const getDataWithProvenanceAsync = async (): Promise<RequestResult<TData>> => {
|
||||
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 = <TData, TInput extends Record<string, unknow
|
||||
const entry: CacheEntry<TData> = { 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<Pick<RequestResult<TData>, "data" | "timestamp">> {
|
||||
const { data, timestamp } = await getDataWithProvenanceAsync();
|
||||
return { data, timestamp };
|
||||
},
|
||||
getDataWithProvenanceAsync,
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -27,7 +27,6 @@ export const defaultServerSettings = {
|
||||
board: {
|
||||
homeBoardId: null as string | null,
|
||||
mobileHomeBoardId: null as string | null,
|
||||
enableAutomaticMobileLayout: true,
|
||||
enableStatusByDefault: true,
|
||||
forceDisableStatus: false,
|
||||
},
|
||||
|
||||
@@ -12,14 +12,14 @@ export type SettingsContextProps = Pick<
|
||||
| "pingIconsEnabled"
|
||||
| "enableRightClickOnWidgets"
|
||||
> &
|
||||
Pick<ServerSettings["board"], "enableAutomaticMobileLayout" | "enableStatusByDefault" | "forceDisableStatus"> &
|
||||
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
|
||||
Pick<ServerSettings["user"], "enableGravatar">;
|
||||
|
||||
export interface PublicServerSettings {
|
||||
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
|
||||
board: Pick<
|
||||
ServerSettings["board"],
|
||||
"homeBoardId" | "mobileHomeBoardId" | "enableAutomaticMobileLayout" | "enableStatusByDefault" | "forceDisableStatus"
|
||||
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
|
||||
>;
|
||||
user: Pick<ServerSettings["user"], "enableGravatar">;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Record<UserPreferenceKey, (value: unknown) => void>> = {
|
||||
colorScheme: (value) => setColorScheme(value as ColorScheme),
|
||||
locale: (value) => requestBoardEditAction(() => changeLocale(value as typeof currentLocale)),
|
||||
locale: (value) => changeLocale(value as typeof currentLocale),
|
||||
fullPreferencesPage: () => undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<number>();
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
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),
|
||||
|
||||
@@ -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 (
|
||||
<WidgetMobileSummary
|
||||
value={data.length}
|
||||
label={options.title || t("widget.bookmarks.name")}
|
||||
description={
|
||||
data
|
||||
.map((app) => app.name)
|
||||
.slice(0, 2)
|
||||
.join(" · ") || t("board.mobile.details")
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="sm" p="sm">
|
||||
{options.title.length > 0 && (
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 <WidgetMobileLoading />;
|
||||
throwOnInitialQueryError(error, data !== undefined);
|
||||
if (displayMode === "mobileSummary" && isPending) return <WidgetMobileLoading />;
|
||||
if (displayMode === "mobileSummary" && error && !data) throw error;
|
||||
|
||||
return (
|
||||
<CalendarBase
|
||||
@@ -56,11 +55,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
|
||||
setMonth={setMonth}
|
||||
options={options}
|
||||
displayMode={displayMode}
|
||||
isStale={hasWidgetDataWarning({
|
||||
error,
|
||||
expectedIntegrationCount: integrationIds.length,
|
||||
receivedIntegrationCount: data?.length,
|
||||
})}
|
||||
isStale={Boolean(error) || (data?.length ?? 0) < integrationIds.length}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -130,7 +125,7 @@ const CalendarBase = ({
|
||||
);
|
||||
}
|
||||
|
||||
const calendar = (
|
||||
return (
|
||||
<Calendar
|
||||
defaultDate={new Date()}
|
||||
onPreviousMonth={(month) => setMonth(new Date(month))}
|
||||
@@ -210,7 +205,6 @@ const CalendarBase = ({
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <WidgetDataState hasWarning={isStale}>{calendar}</WidgetDataState>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Box pos="relative" h="100%" w="100%">
|
||||
{hasWarning && (
|
||||
<Tooltip label={label} position="left" withArrow>
|
||||
<Center
|
||||
component="output"
|
||||
aria-label={label}
|
||||
pos="absolute"
|
||||
bottom={4}
|
||||
w={28}
|
||||
h={28}
|
||||
style={{
|
||||
insetInlineStart: 4,
|
||||
zIndex: 4,
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
background: "light-dark(var(--mantine-color-yellow-1), var(--mantine-color-dark-6))",
|
||||
}}
|
||||
>
|
||||
<IconAlertTriangle
|
||||
size={16}
|
||||
color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"
|
||||
aria-hidden
|
||||
/>
|
||||
</Center>
|
||||
</Tooltip>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 <DownloadsMobileSummary integrationIds={props.integrationIds} options={props.options} />;
|
||||
}
|
||||
|
||||
return <DownloadClientsWidget {...props} />;
|
||||
}
|
||||
|
||||
function DownloadsMobileSummary({
|
||||
integrationIds,
|
||||
options,
|
||||
}: Pick<WidgetComponentProps<"downloads">, "integrationIds" | "options">) {
|
||||
const t = useScopedI18n("widget.downloads");
|
||||
const { data, error, isPending } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
|
||||
integrationIds,
|
||||
limitPerIntegration: options.limitPerIntegration,
|
||||
});
|
||||
|
||||
if (isPending) return <WidgetMobileLoading />;
|
||||
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 (
|
||||
<WidgetMobileSummary
|
||||
value={visibleItemCount}
|
||||
label={t("name")}
|
||||
description={`${t("items.downSpeed.columnTitle")}: ${formatByteRate(totalDownloadSpeed)}`}
|
||||
isStale={hasWidgetDataWarning({
|
||||
error,
|
||||
expectedIntegrationCount: integrationIds.length,
|
||||
receivedIntegrationCount: currentItems.length,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadClientsWidget({
|
||||
export default function DownloadClientsWidget({
|
||||
isEditMode,
|
||||
integrationIds,
|
||||
options,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 <WidgetMobileLoading />;
|
||||
throwOnInitialQueryError(error, data !== undefined);
|
||||
|
||||
const releases = data?.items ?? [];
|
||||
const hasWarning = hasWidgetDataWarning({
|
||||
error,
|
||||
failedIntegrationCount: data?.failedIntegrationCount,
|
||||
staleIntegrationCount: data?.staleIntegrationCount,
|
||||
});
|
||||
|
||||
if (displayMode === "mobileSummary") {
|
||||
if (isPending) return <WidgetMobileLoading />;
|
||||
if (error && !data) throw error;
|
||||
|
||||
const releases = data ?? [];
|
||||
return (
|
||||
<WidgetMobileSummary
|
||||
value={releases.length}
|
||||
label={t("name")}
|
||||
description={releases[0]?.title}
|
||||
isStale={hasWarning}
|
||||
isStale={Boolean(error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const content = (
|
||||
const releases = data ?? [];
|
||||
|
||||
return (
|
||||
<Stack p="xs" gap="sm">
|
||||
{releases.map((item, index) => (
|
||||
<Fragment key={item.id}>
|
||||
@@ -60,11 +55,10 @@ export default function MediaReleasesWidget({
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
return <WidgetDataState hasWarning={hasWarning}>{content}</WidgetDataState>;
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleasesWithProvenance"]["items"][number];
|
||||
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number];
|
||||
options: WidgetComponentProps<"mediaReleases">["options"];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <WidgetMobileLoading />;
|
||||
throwOnInitialQueryError(error, data !== undefined);
|
||||
if (!data) return <WidgetEmptyState />;
|
||||
|
||||
const hasWarning = hasWidgetDataWarning({
|
||||
error,
|
||||
failedIntegrationCount: data.failedIntegrationCount,
|
||||
staleIntegrationCount: data.staleIntegrationCount,
|
||||
});
|
||||
|
||||
if (data.items.length === 0 && !hasWarning) throw new NoIntegrationDataError();
|
||||
if (displayMode === "mobileSummary" && isPending) return <WidgetMobileLoading />;
|
||||
if (displayMode === "mobileSummary" && error && !mediaRequests) throw error;
|
||||
if (!mediaRequests) return <WidgetEmptyState />;
|
||||
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
|
||||
|
||||
if (displayMode === "mobileSummary") {
|
||||
return (
|
||||
<WidgetMobileSummary
|
||||
value={data.items.length}
|
||||
value={mediaRequests.length}
|
||||
label={t("name")}
|
||||
description={data.items[0]?.name}
|
||||
isStale={hasWarning}
|
||||
description={mediaRequests[0]?.name}
|
||||
isStale={Boolean(error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.items.length === 0) {
|
||||
return (
|
||||
<WidgetDataState hasWarning={hasWarning}>
|
||||
<WidgetEmptyState />
|
||||
</WidgetDataState>
|
||||
);
|
||||
}
|
||||
|
||||
const content = (
|
||||
return (
|
||||
<Box className={classes.searchRoot}>
|
||||
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||
<ScrollArea
|
||||
@@ -89,7 +77,7 @@ export default function MediaServerWidget({
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
|
||||
{data.items.map((mediaRequest) => (
|
||||
{mediaRequests.map((mediaRequest) => (
|
||||
<MediaRequestCard
|
||||
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
||||
request={mediaRequest}
|
||||
@@ -101,7 +89,6 @@ export default function MediaServerWidget({
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
return <WidgetDataState hasWarning={hasWarning}>{content}</WidgetDataState>;
|
||||
}
|
||||
|
||||
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"];
|
||||
}
|
||||
|
||||
@@ -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 <WidgetMobileLoading />;
|
||||
throwOnInitialQueryError(error, requestStats !== undefined);
|
||||
if (!requestStats) return <WidgetEmptyState />;
|
||||
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 (
|
||||
<WidgetMobileSummary
|
||||
value={total}
|
||||
label={t("titles.stats.total")}
|
||||
description={`${pending} ${t("titles.stats.pending")}`}
|
||||
isStale={hasWarning}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isTiny = width < 256;
|
||||
|
||||
const content = (
|
||||
return (
|
||||
<Box className={searchClasses.searchRoot}>
|
||||
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||
<Stack
|
||||
@@ -203,7 +173,6 @@ export default function MediaServerWidget({
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
return <WidgetDataState hasWarning={hasWarning}>{content}</WidgetDataState>;
|
||||
}
|
||||
|
||||
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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 <WidgetMobileLoading />;
|
||||
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<WidgetComponentProps<"mediaServer">, "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<MRT_ColumnDef<StreamSession>[]>(
|
||||
@@ -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 (
|
||||
<Stack gap={0} h="100%" display="flex">
|
||||
<MantineReactTable table={table} />
|
||||
<Group
|
||||
@@ -328,7 +317,6 @@ const MediaServerTable = ({
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
return <WidgetDataState hasWarning={hasWarning}>{content}</WidgetDataState>;
|
||||
};
|
||||
|
||||
const ItemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => {
|
||||
|
||||
@@ -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 <WidgetMobileLoading />;
|
||||
throwOnInitialQueryError(error, notificationIntegrations !== undefined);
|
||||
const hasWarning = hasWidgetDataWarning({
|
||||
error,
|
||||
expectedIntegrationCount: integrationIds.length,
|
||||
receivedIntegrationCount: notificationIntegrations?.length,
|
||||
});
|
||||
|
||||
if (displayMode === "mobileSummary") {
|
||||
if (isPending) return <WidgetMobileLoading />;
|
||||
if (error && !notificationIntegrations) throw error;
|
||||
|
||||
const notifications = notificationIntegrations ?? [];
|
||||
const latestNotification = sortedNotifications[0];
|
||||
const isStale = Boolean(error) || notifications.length < integrationIds.length;
|
||||
const summary = latestNotification ? (
|
||||
<NotificationMobileSummary notification={latestNotification} isStale={hasWarning} />
|
||||
<NotificationMobileSummary notification={latestNotification} isStale={isStale} />
|
||||
) : (
|
||||
<WidgetMobileSummary value={0} label={t("name")} description={t("noItems")} isStale={hasWarning} />
|
||||
<WidgetMobileSummary value={0} label={t("name")} description={t("noItems")} isStale={isStale} />
|
||||
);
|
||||
|
||||
if (!latestNotification?.href) return summary;
|
||||
@@ -72,7 +68,7 @@ export default function NotificationsWidget({
|
||||
);
|
||||
}
|
||||
|
||||
const content = (
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" p="sm">
|
||||
<Stack w={"100%"} gap="sm">
|
||||
{sortedNotifications.length > 0 ? (
|
||||
@@ -121,7 +117,6 @@ export default function NotificationsWidget({
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
return <WidgetDataState hasWarning={hasWarning}>{content}</WidgetDataState>;
|
||||
}
|
||||
|
||||
const NotificationMobileSummary = ({
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 <WidgetMobileLoading />;
|
||||
throwOnInitialQueryError(error, dashboardData !== undefined);
|
||||
if (displayMode === "mobileSummary" && isPending) return <WidgetMobileLoading />;
|
||||
if (displayMode === "mobileSummary" && error && !dashboardData) throw error;
|
||||
if (!dashboardData) return <WidgetEmptyState />;
|
||||
const hasWarning = hasWidgetDataWarning({
|
||||
error,
|
||||
expectedIntegrationCount: integrationIds.length,
|
||||
receivedIntegrationCount: dashboardData.length,
|
||||
});
|
||||
|
||||
const combined = dashboardData.reduce<UptimeKumaDashboardData>(
|
||||
(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 <WidgetMobileSummary value="—" label={t("name")} isStale={hasWarning} />;
|
||||
return <WidgetMobileSummary value="—" label={t("name")} isStale={Boolean(error) || hasPartialFailure} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={classes.root}>
|
||||
{showHero && (
|
||||
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
|
||||
@@ -246,7 +241,6 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <WidgetDataState hasWarning={hasWarning}>{content}</WidgetDataState>;
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
|
||||
Generated
-6
@@ -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
|
||||
|
||||
Reference in new issue
Block a user