Revert "feat: complete automatic mobile board UX"

This reverts commit 7959c20d88.
This commit is contained in:
ajnart committed 2026-08-13 19:55:21 +02:00
1 parent e93293f8a9
commit be9c69f325
41 files changed
+270 -1225

No files matched your search

@@ -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;
};
@@ -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}
-1
View File
@@ -115,7 +115,6 @@ export default async function Layout(props: {
board: {
homeBoardId: serverSettings.board.homeBoardId,
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
enableAutomaticMobileLayout: serverSettings.board.enableAutomaticMobileLayout,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
},
@@ -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,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);
@@ -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,10 +1,10 @@
import { useCallback } from "react";
import { getCurrentLayout } from "@homarr/boards/context";
import { useUpdateBoard } from "@homarr/boards/updater";
interface MoveAndResizeInnerSection {
innerSectionId: string;
layoutId: string;
xOffset: number;
yOffset: number;
width: number;
@@ -12,7 +12,6 @@ interface MoveAndResizeInnerSection {
}
interface MoveInnerSectionToSection {
innerSectionId: string;
layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
@@ -24,7 +23,7 @@ export const useSectionActions = () => {
const { updateBoard } = useUpdateBoard();
const moveAndResizeInnerSection = useCallback(
({ innerSectionId, layoutId, ...positionProps }: MoveAndResizeInnerSection) => {
({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((section) => {
@@ -32,10 +31,12 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "container") return section;
const currentLayout = getCurrentLayout(previous);
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.layoutId !== layoutId) return layout;
if (layout.layoutId !== currentLayout) return layout;
return {
...layout,
...positionProps,
@@ -49,7 +50,7 @@ export const useSectionActions = () => {
);
const moveInnerSectionToSection = useCallback(
({ innerSectionId, layoutId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
updateBoard((previous) => {
return {
...previous,
@@ -58,10 +59,12 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "container") return section;
const currentLayout = getCurrentLayout(previous);
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.layoutId !== layoutId) return layout;
if (layout.layoutId !== currentLayout) return layout;
return {
...layout,
...positionProps,
@@ -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}
+22 -131
View File
@@ -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();
+4 -14
View File
@@ -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")
@@ -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);
+1 -17
View File
@@ -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,
});
});
});
-1
View File
@@ -25,7 +25,6 @@
},
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0",
"react": "catalog:",
"react-dom": "catalog:"
},
-36
View File
@@ -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"]);
});
});
-30
View File
@@ -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();
});
});
-22
View File
@@ -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
-106
View File
@@ -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);
});
});
+4 -19
View File
@@ -21,7 +21,6 @@ import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
import {
getServerSettingByKeyAsync,
insertServerSettingByKeyAsync,
shouldEnableAutomaticMobileLayoutForUpgrade,
updateServerSettingByKeyAsync,
} from "../queries/server-setting";
@@ -156,33 +155,19 @@ const seedDefaultSearchEnginesAsync = async (db: Database) => {
console.log("Set Homarr docs as the default search engine");
};
export const seedServerSettingsAsync = async (db: Database) => {
const seedServerSettingsAsync = async (db: Database) => {
const serverSettingsData = await db.query.serverSettings.findMany();
const existingLayouts = await db.query.layouts.findMany({
columns: {
boardId: true,
name: true,
breakpoint: true,
},
});
const defaultsForThisInstance = {
...defaultServerSettings,
board: {
...defaultServerSettings.board,
enableAutomaticMobileLayout: shouldEnableAutomaticMobileLayoutForUpgrade(existingLayouts),
},
};
for (const settingsKey of defaultServerSettingsKeys) {
const currentDbEntry = serverSettingsData.find((setting) => setting.settingKey === settingsKey);
if (!currentDbEntry) {
await insertServerSettingByKeyAsync(db, settingsKey, defaultsForThisInstance[settingsKey]);
await insertServerSettingByKeyAsync(db, settingsKey, defaultServerSettings[settingsKey]);
console.log(`Created serverSetting through seed key=${settingsKey}`);
continue;
}
const currentSettings = SuperJSON.parse<(typeof defaultServerSettings)[typeof settingsKey]>(currentDbEntry.value);
const defaultSettings = defaultsForThisInstance[settingsKey];
const currentSettings = await getServerSettingByKeyAsync(db, settingsKey);
const defaultSettings = defaultServerSettings[settingsKey];
const missingKeys = objectKeys(defaultSettings).filter((key) => !(key in currentSettings));
if (missingKeys.length === 0) {
+9 -78
View File
@@ -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),
});
};
-1
View File
@@ -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">;
+1 -6
View File
@@ -128,7 +128,6 @@ interface OpenModalOptions {
keepMounted?: boolean;
onClose?: () => void;
title?: stringOrTranslation;
onClose?: () => void;
}
export const useModalAction = <TModal extends ModalDefinition>(modal: TModal) => {
@@ -149,10 +148,6 @@ export const useConfirmModal = () => {
const { openModal } = useModalAction(ConfirmModal);
return {
openConfirmModal: (props: ConfirmModalProps) =>
openModal(props, {
title: props.title,
onClose: props.onClose,
}),
openConfirmModal: (props: ConfirmModalProps) => openModal(props, { title: props.title }),
};
};
-1
View File
@@ -27,7 +27,6 @@ export const defaultServerSettings = {
board: {
homeBoardId: null as string | null,
mobileHomeBoardId: null as string | null,
enableAutomaticMobileLayout: true,
enableStatusByDefault: true,
forceDisableStatus: false,
},
+2 -3
View File
@@ -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,
-1
View File
@@ -26,7 +26,6 @@
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/boards": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
@@ -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,
};
@@ -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>
);
}
+1 -1
View File
@@ -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) => ({
@@ -10,7 +10,7 @@ export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
supportsAdvancedFocus: false,
icon: IconDeviceGamepad,
mobile: { supportsDetailView: true, unmountWhenOffscreen: true },
mobile: { supportsDetailView: true },
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() {
@@ -10,7 +10,6 @@ export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
supportsAdvancedFocus: false,
icon: IconAd,
mobile: { unmountWhenOffscreen: true },
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() {