feat: complete automatic mobile board UX
This commit is contained in:
1 parent
b217d8e026
commit
e6bd1adfc2
50 files changed
+1279
-343
No files matched your search
@@ -82,6 +82,8 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
|
||||
- Set desktop and mobile home boards for the current user
|
||||
- Configure global board defaults such as public home boards and status behavior
|
||||
|
||||
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
|
||||
|
||||
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
||||
|
||||
## Partial integration failures
|
||||
|
||||
@@ -4,14 +4,18 @@ import { shouldPrefetchWidgetForRequest } from "./_prefetch-policy";
|
||||
|
||||
describe("shouldPrefetchWidgetForRequest", () => {
|
||||
test.each(["mobile", "tablet"])("defers downloads for %s requests", (deviceType) => {
|
||||
expect(shouldPrefetchWidgetForRequest("downloads", deviceType)).toBe(false);
|
||||
expect(shouldPrefetchWidgetForRequest("downloads", deviceType, true)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps downloads prefetched for desktop requests", () => {
|
||||
expect(shouldPrefetchWidgetForRequest("downloads", undefined)).toBe(true);
|
||||
expect(shouldPrefetchWidgetForRequest("downloads", undefined, true)).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps downloads prefetched for legacy responsive mobile boards", () => {
|
||||
expect(shouldPrefetchWidgetForRequest("downloads", "mobile", false)).toBe(true);
|
||||
});
|
||||
|
||||
test.each(["app", "bookmarks"] as const)("keeps eager %s data prefetched on mobile", (kind) => {
|
||||
expect(shouldPrefetchWidgetForRequest(kind, "mobile")).toBe(true);
|
||||
expect(shouldPrefetchWidgetForRequest(kind, "mobile", true)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
export const shouldPrefetchWidgetForRequest = (kind: WidgetKind, deviceType: string | undefined) => {
|
||||
export const shouldPrefetchWidgetForRequest = (
|
||||
kind: WidgetKind,
|
||||
deviceType: string | undefined,
|
||||
enableAutomaticMobileLayout: boolean,
|
||||
) => {
|
||||
const isMobileDevice = deviceType === "mobile" || deviceType === "tablet";
|
||||
|
||||
return kind !== "downloads" || !isMobileDevice;
|
||||
return kind !== "downloads" || !isMobileDevice || !enableAutomaticMobileLayout;
|
||||
};
|
||||
@@ -53,7 +53,7 @@ export default async function BoardSettingsPage(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="xl">
|
||||
<Container size="xl" px={{ base: "md", md: "xl" }}>
|
||||
<Stack>
|
||||
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
|
||||
<BoardSettingsForm
|
||||
|
||||
@@ -48,13 +48,22 @@ 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={isCurrent ? <IconCheck size={20} /> : <IconLayoutBoard size={20} />}
|
||||
aria-current={isCurrent ? "page" : undefined}
|
||||
leftSection={<IconLayoutBoard size={20} />}
|
||||
>
|
||||
<Text truncate title={availableBoard.name}>
|
||||
{availableBoard.name}
|
||||
|
||||
@@ -129,6 +129,7 @@ export default async function Layout(props: {
|
||||
board: {
|
||||
homeBoardId: serverSettings.board.homeBoardId,
|
||||
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
|
||||
enableAutomaticMobileLayout: serverSettings.board.enableAutomaticMobileLayout,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
},
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { getCurrentLayout } from "@homarr/boards/context";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface MoveAndResizeItemInput {
|
||||
itemId: string;
|
||||
layoutId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
@@ -11,10 +10,8 @@ export interface MoveAndResizeItemInput {
|
||||
}
|
||||
|
||||
export const moveAndResizeItemCallback =
|
||||
({ itemId, ...layoutInput }: MoveAndResizeItemInput) =>
|
||||
({ itemId, layoutId, ...layoutInput }: MoveAndResizeItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.map((item) =>
|
||||
@@ -23,7 +20,7 @@ export const moveAndResizeItemCallback =
|
||||
: {
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) =>
|
||||
layout.layoutId !== currentLayout
|
||||
layout.layoutId !== layoutId
|
||||
? layout
|
||||
: {
|
||||
...layout,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { getCurrentLayout } from "@homarr/boards/context";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface MoveItemToSectionInput {
|
||||
itemId: string;
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
@@ -12,10 +11,8 @@ export interface MoveItemToSectionInput {
|
||||
}
|
||||
|
||||
export const moveItemToSectionCallback =
|
||||
({ itemId, ...layoutInput }: MoveItemToSectionInput) =>
|
||||
({ itemId, layoutId, ...layoutInput }: MoveItemToSectionInput) =>
|
||||
(board: Board): Board => {
|
||||
const currentLayout = getCurrentLayout(board);
|
||||
|
||||
return {
|
||||
...board,
|
||||
items: board.items.map((item) =>
|
||||
@@ -24,7 +21,7 @@ export const moveItemToSectionCallback =
|
||||
: {
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) =>
|
||||
layout.layoutId !== currentLayout
|
||||
layout.layoutId !== layoutId
|
||||
? layout
|
||||
: {
|
||||
...layout,
|
||||
|
||||
@@ -14,6 +14,20 @@ describe("item actions create-item", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("clips oversized widgets to each layout's column count", () => {
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 })
|
||||
.addEmptySection({ id: "section" })
|
||||
.build();
|
||||
const baseLayout = board.layouts.find((layout) => layout.role === "base");
|
||||
|
||||
const result = createItemCallback({ kind: "mediaMissing" })(board);
|
||||
|
||||
const createdItem = result.items.at(0);
|
||||
expect(createdItem?.layouts.find((layout) => layout.layoutId === "mobile")?.width).toBe(3);
|
||||
expect(createdItem?.layouts.find((layout) => layout.layoutId === baseLayout?.id)?.width).toBe(4);
|
||||
});
|
||||
|
||||
test("should add it to first section", () => {
|
||||
// Arrange
|
||||
const itemKind = "clock";
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { moveAndResizeItemCallback } from "../move-and-resize-item";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
@@ -11,8 +9,6 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
xOffset: 20,
|
||||
yOffset: 30,
|
||||
@@ -25,7 +21,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(3);
|
||||
@@ -43,8 +39,6 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
xOffset: 20,
|
||||
yOffset: 30,
|
||||
@@ -56,7 +50,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(2);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { moveItemToSectionCallback } from "../move-item-to-section";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
@@ -11,8 +9,6 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
sectionId: "3",
|
||||
xOffset: 20,
|
||||
@@ -26,7 +22,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(3);
|
||||
@@ -44,8 +40,6 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
sectionId: "3",
|
||||
xOffset: 20,
|
||||
@@ -58,7 +52,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(2);
|
||||
|
||||
@@ -5,66 +5,56 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Skeleton } from "@mantine/core";
|
||||
|
||||
import classes from "./mobile-board.module.css";
|
||||
import { observeMobileViewportProximity } from "./mobile-viewport-observer";
|
||||
|
||||
interface DeferredMobileItemProps extends PropsWithChildren {
|
||||
eager: boolean;
|
||||
unmountWhenOffscreen: boolean;
|
||||
onNearViewportChange?: (isNearViewport: boolean) => void;
|
||||
}
|
||||
|
||||
export const mobilePreloadViewportMultiplier = 2;
|
||||
export { getMobilePreloadRootMargin, mobilePreloadViewportMultiplier } from "./mobile-viewport-observer";
|
||||
|
||||
export const getMobilePreloadRootMargin = (viewportHeight: number) =>
|
||||
`${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`;
|
||||
|
||||
export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, children }: DeferredMobileItemProps) => {
|
||||
export const DeferredMobileItem = ({
|
||||
eager,
|
||||
unmountWhenOffscreen,
|
||||
onNearViewportChange,
|
||||
children,
|
||||
}: DeferredMobileItemProps) => {
|
||||
const [hasMounted, setHasMounted] = useState(eager);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasMountedOnceRef = useRef(eager);
|
||||
|
||||
useEffect(() => {
|
||||
if (eager) {
|
||||
hasMountedOnceRef.current = true;
|
||||
setHasMounted(true);
|
||||
onNearViewportChange?.(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const element = wrapperRef.current;
|
||||
if (!element || typeof IntersectionObserver === "undefined") {
|
||||
if (!element) {
|
||||
setHasMounted(true);
|
||||
onNearViewportChange?.(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let observer: IntersectionObserver | null = null;
|
||||
const observe = () => {
|
||||
if (hasMountedOnceRef.current && !unmountWhenOffscreen) return;
|
||||
observer?.disconnect();
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
const isNearViewport = entry?.isIntersecting ?? false;
|
||||
return observeMobileViewportProximity(
|
||||
element,
|
||||
(isNearViewport) => {
|
||||
onNearViewportChange?.(isNearViewport);
|
||||
|
||||
if (isNearViewport) {
|
||||
hasMountedOnceRef.current = true;
|
||||
setHasMounted(true);
|
||||
if (!unmountWhenOffscreen) observer?.disconnect();
|
||||
return;
|
||||
}
|
||||
if (isNearViewport) {
|
||||
setHasMounted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (unmountWhenOffscreen && !element.contains(document.activeElement)) {
|
||||
setHasMounted(false);
|
||||
}
|
||||
},
|
||||
{ rootMargin: getMobilePreloadRootMargin(window.innerHeight) },
|
||||
);
|
||||
observer.observe(element);
|
||||
};
|
||||
|
||||
observe();
|
||||
window.addEventListener("resize", observe);
|
||||
return () => {
|
||||
window.removeEventListener("resize", observe);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [eager, unmountWhenOffscreen]);
|
||||
if (unmountWhenOffscreen && !element.contains(document.activeElement)) {
|
||||
setHasMounted(false);
|
||||
}
|
||||
},
|
||||
{ once: !unmountWhenOffscreen },
|
||||
);
|
||||
}, [eager, onNearViewportChange, unmountWhenOffscreen]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={classes.deferredItem} aria-busy={!hasMounted}>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, RefObject } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Box, Center, Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconLayoutBoard } from "@tabler/icons-react";
|
||||
|
||||
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -63,6 +64,8 @@ const getMobilePresentation = (item: SectionItem) => {
|
||||
const MobileBoardItem = ({ item }: { item: SectionItem }) => {
|
||||
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
|
||||
const presentation = getMobilePresentation(item);
|
||||
const [isNearViewport, setIsNearViewport] = useState(presentation.eager);
|
||||
const [detailsOpened, detailsDisclosure] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -71,16 +74,30 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => {
|
||||
data-mobile-board-item={item.id}
|
||||
data-mobile-display-mode={presentation.displayMode}
|
||||
>
|
||||
<DeferredMobileItem eager={presentation.eager} unmountWhenOffscreen={presentation.unmountWhenOffscreen}>
|
||||
<DeferredMobileItem
|
||||
eager={presentation.eager}
|
||||
unmountWhenOffscreen={presentation.unmountWhenOffscreen}
|
||||
onNearViewportChange={setIsNearViewport}
|
||||
>
|
||||
<BoardItemContent
|
||||
item={item}
|
||||
displayMode={presentation.displayMode}
|
||||
disableContextMenu
|
||||
isReadOnly
|
||||
widgetStateRef={widgetStateRef}
|
||||
onOpenDetails={presentation.usesGenericSummary ? detailsDisclosure.open : undefined}
|
||||
/>
|
||||
</DeferredMobileItem>
|
||||
<MobileWidgetActions item={item} supportsDetails={presentation.supportsDetails} widgetStateRef={widgetStateRef} />
|
||||
<MobileWidgetActions
|
||||
item={item}
|
||||
supportsDetails={presentation.supportsDetails}
|
||||
detailsFromCard={presentation.usesGenericSummary}
|
||||
detailsOpened={detailsOpened}
|
||||
openDetails={detailsDisclosure.open}
|
||||
closeDetails={detailsDisclosure.close}
|
||||
widgetStateRef={widgetStateRef}
|
||||
isNearViewport={isNearViewport}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
isMobileContextActionVisible,
|
||||
resolveMobileItemPresentation,
|
||||
shouldKeepMobileWidgetActionsMounted,
|
||||
shouldRenderMobileWidgetActions,
|
||||
} from "./mobile-presentation";
|
||||
|
||||
@@ -42,7 +43,7 @@ describe("resolveMobileItemPresentation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps automatic footprint defaults when only details are configured", () => {
|
||||
test("uses a compact one-row fallback when only details are configured", () => {
|
||||
expect(
|
||||
resolveMobileItemPresentation(
|
||||
{ kind: "calendar", height: 7 },
|
||||
@@ -52,8 +53,10 @@ describe("resolveMobileItemPresentation", () => {
|
||||
),
|
||||
).toMatchObject({
|
||||
width: 2,
|
||||
height: 3,
|
||||
height: 1,
|
||||
displayMode: "mobileSummary",
|
||||
supportsDetails: true,
|
||||
usesGenericSummary: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +78,7 @@ describe("resolveMobileItemPresentation", () => {
|
||||
height: 1,
|
||||
displayMode: "mobileSummary",
|
||||
supportsDetails: false,
|
||||
usesGenericSummary: false,
|
||||
eager: true,
|
||||
unmountWhenOffscreen: false,
|
||||
});
|
||||
@@ -101,6 +105,42 @@ describe("shouldRenderMobileWidgetActions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldKeepMobileWidgetActionsMounted", () => {
|
||||
test("does not expose actions before a deferred widget is near the viewport", () => {
|
||||
expect(
|
||||
shouldKeepMobileWidgetActionsMounted({
|
||||
isNearViewport: false,
|
||||
actionsOpened: false,
|
||||
detailsOpened: false,
|
||||
isOpeningDetails: false,
|
||||
isCompletingAction: false,
|
||||
actionTriggerHasFocus: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["near the viewport", { isNearViewport: true }],
|
||||
["showing its action drawer", { actionsOpened: true }],
|
||||
["showing its detail view", { detailsOpened: true }],
|
||||
["transitioning to details", { isOpeningDetails: true }],
|
||||
["completing an action", { isCompletingAction: true }],
|
||||
["retaining keyboard focus", { actionTriggerHasFocus: true }],
|
||||
])("keeps actions mounted while %s", (_label, activeState) => {
|
||||
expect(
|
||||
shouldKeepMobileWidgetActionsMounted({
|
||||
isNearViewport: false,
|
||||
actionsOpened: false,
|
||||
detailsOpened: false,
|
||||
isOpeningDetails: false,
|
||||
isCompletingAction: false,
|
||||
actionTriggerHasFocus: false,
|
||||
...activeState,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMobileContextActionVisible", () => {
|
||||
test("requires actions to opt into the read-only mobile surface", () => {
|
||||
const action = { key: "configure", label: "Configure", onClick: () => undefined };
|
||||
|
||||
@@ -4,14 +4,21 @@ import type { WidgetContextMenuAction, WidgetMobilePresentation } from "@homarr/
|
||||
export const resolveMobileItemPresentation = (
|
||||
item: { kind: WidgetKind; height: number },
|
||||
mobile: WidgetMobilePresentation | undefined,
|
||||
) => ({
|
||||
width: mobile?.width ?? (item.kind === "app" ? 1 : 2),
|
||||
height: mobile?.height ?? (item.kind === "app" ? 1 : Math.max(1, Math.min(item.height, 3))),
|
||||
displayMode: mobile?.supportsCompactSummary ? ("mobileSummary" as const) : ("default" as const),
|
||||
supportsDetails: mobile?.supportsDetailView === true,
|
||||
eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"),
|
||||
unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false),
|
||||
});
|
||||
) => {
|
||||
const supportsCompactSummary = mobile?.supportsCompactSummary === true;
|
||||
const supportsDetails = mobile?.supportsDetailView === true;
|
||||
const usesGenericSummary = supportsDetails && !supportsCompactSummary;
|
||||
|
||||
return {
|
||||
width: mobile?.width ?? (item.kind === "app" ? 1 : 2),
|
||||
height: mobile?.height ?? (item.kind === "app" || usesGenericSummary ? 1 : Math.max(1, Math.min(item.height, 3))),
|
||||
displayMode: supportsCompactSummary || usesGenericSummary ? ("mobileSummary" as const) : ("default" as const),
|
||||
supportsDetails,
|
||||
usesGenericSummary,
|
||||
eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"),
|
||||
unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false),
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldRenderMobileWidgetActions = ({
|
||||
supportsDetails,
|
||||
@@ -23,5 +30,24 @@ export const shouldRenderMobileWidgetActions = ({
|
||||
visibleContextActionCount: number;
|
||||
}) => supportsDetails || supportsRefresh || visibleContextActionCount > 0;
|
||||
|
||||
interface ShouldKeepMobileWidgetActionsMountedInput {
|
||||
isNearViewport: boolean;
|
||||
actionsOpened: boolean;
|
||||
detailsOpened: boolean;
|
||||
isOpeningDetails: boolean;
|
||||
isCompletingAction: boolean;
|
||||
actionTriggerHasFocus: boolean;
|
||||
}
|
||||
|
||||
export const shouldKeepMobileWidgetActionsMounted = ({
|
||||
isNearViewport,
|
||||
actionsOpened,
|
||||
detailsOpened,
|
||||
isOpeningDetails,
|
||||
isCompletingAction,
|
||||
actionTriggerHasFocus,
|
||||
}: ShouldKeepMobileWidgetActionsMountedInput) =>
|
||||
isNearViewport || actionsOpened || detailsOpened || isOpeningDetails || isCompletingAction || actionTriggerHasFocus;
|
||||
|
||||
export const isMobileContextActionVisible = (action: WidgetContextMenuAction) =>
|
||||
action.mobileVisible === true && !action.hidden;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { getMobilePreloadRootMargin, observeMobileViewportProximity } from "./mobile-viewport-observer";
|
||||
|
||||
class IntersectionObserverMock {
|
||||
static instances: IntersectionObserverMock[] = [];
|
||||
|
||||
readonly observedElements = new Set<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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
export const mobilePreloadViewportMultiplier = 2;
|
||||
|
||||
export const getMobilePreloadRootMargin = (viewportHeight: number) =>
|
||||
`${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`;
|
||||
|
||||
interface MobileViewportSubscription {
|
||||
listener: (isNearViewport: boolean) => void;
|
||||
once: boolean;
|
||||
}
|
||||
|
||||
interface ObserveMobileViewportOptions {
|
||||
once?: boolean;
|
||||
}
|
||||
|
||||
const subscriptions = new Map<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 } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { ActionIcon, Box, Button, Drawer, Loader, Modal, Stack } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconDotsVertical, IconMaximize, IconRefresh } from "@tabler/icons-react";
|
||||
@@ -20,20 +20,40 @@ import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { useHasNestedDialog } from "./mobile-dialog-stack";
|
||||
import classes from "./mobile-board.module.css";
|
||||
import { isMobileContextActionVisible, shouldRenderMobileWidgetActions } from "./mobile-presentation";
|
||||
import {
|
||||
isMobileContextActionVisible,
|
||||
shouldKeepMobileWidgetActionsMounted,
|
||||
shouldRenderMobileWidgetActions,
|
||||
} from "./mobile-presentation";
|
||||
|
||||
interface MobileWidgetActionsProps {
|
||||
item: SectionItem;
|
||||
supportsDetails: boolean;
|
||||
detailsFromCard: boolean;
|
||||
detailsOpened: boolean;
|
||||
openDetails: () => void;
|
||||
closeDetails: () => void;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
isNearViewport: boolean;
|
||||
}
|
||||
|
||||
export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => {
|
||||
export const MobileWidgetActions = ({
|
||||
item,
|
||||
supportsDetails,
|
||||
detailsFromCard,
|
||||
detailsOpened,
|
||||
openDetails,
|
||||
closeDetails,
|
||||
widgetStateRef,
|
||||
isNearViewport,
|
||||
}: MobileWidgetActionsProps) => {
|
||||
const [actionsOpened, actionsDisclosure] = useDisclosure(false);
|
||||
const [detailsOpened, detailsDisclosure] = useDisclosure(false);
|
||||
const [isOpeningDetails, setIsOpeningDetails] = useState(false);
|
||||
const [isCompletingAction, setIsCompletingAction] = useState(false);
|
||||
const [actionTriggerHasFocus, setActionTriggerHasFocus] = useState(false);
|
||||
const actionTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const detailsDialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const openDetailsAfterActionsRef = useRef(false);
|
||||
const pendingContextActionRef = useRef<(() => void) | null>(null);
|
||||
const hasNestedDialog = useHasNestedDialog(detailsOpened, detailsDialogRef);
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
@@ -59,133 +79,169 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
|
||||
const visibleContextActions = contextActions.filter(isMobileContextActionVisible);
|
||||
const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
const hasActions = shouldRenderMobileWidgetActions({
|
||||
supportsDetails,
|
||||
supportsDetails: supportsDetails && !detailsFromCard,
|
||||
supportsRefresh: queryKey !== undefined,
|
||||
visibleContextActionCount: visibleContextActions.length,
|
||||
});
|
||||
const shouldKeepMounted = shouldKeepMobileWidgetActionsMounted({
|
||||
isNearViewport,
|
||||
actionsOpened,
|
||||
detailsOpened,
|
||||
isOpeningDetails,
|
||||
isCompletingAction,
|
||||
actionTriggerHasFocus,
|
||||
});
|
||||
|
||||
if (!hasActions) return null;
|
||||
if ((!hasActions && !detailsOpened) || !shouldKeepMounted) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
<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>
|
||||
<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;
|
||||
}
|
||||
|
||||
<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>
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { getCurrentLayout } from "@homarr/boards/context";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
|
||||
interface MoveAndResizeInnerSection {
|
||||
innerSectionId: string;
|
||||
layoutId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
@@ -12,6 +12,7 @@ interface MoveAndResizeInnerSection {
|
||||
}
|
||||
interface MoveInnerSectionToSection {
|
||||
innerSectionId: string;
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
@@ -23,7 +24,7 @@ export const useSectionActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const moveAndResizeInnerSection = useCallback(
|
||||
({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
|
||||
({ innerSectionId, layoutId, ...positionProps }: MoveAndResizeInnerSection) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
@@ -31,12 +32,10 @@ export const useSectionActions = () => {
|
||||
if (section.id !== innerSectionId) return section;
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
return {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.layoutId !== currentLayout) return layout;
|
||||
if (layout.layoutId !== layoutId) return layout;
|
||||
return {
|
||||
...layout,
|
||||
...positionProps,
|
||||
@@ -50,7 +49,7 @@ export const useSectionActions = () => {
|
||||
);
|
||||
|
||||
const moveInnerSectionToSection = useCallback(
|
||||
({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
|
||||
({ innerSectionId, layoutId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
|
||||
updateBoard((previous) => {
|
||||
return {
|
||||
...previous,
|
||||
@@ -59,12 +58,10 @@ export const useSectionActions = () => {
|
||||
if (section.id !== innerSectionId) return section;
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
return {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.layoutId !== currentLayout) return layout;
|
||||
if (layout.layoutId !== layoutId) return layout;
|
||||
return {
|
||||
...layout,
|
||||
...positionProps,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveMobileBoardViewport } from "./use-mobile-board";
|
||||
import {
|
||||
resolveIsAutomaticMobileBoard,
|
||||
resolveMobileBoardViewport,
|
||||
shouldShowMobileBoardViewportSkeleton,
|
||||
} from "./use-mobile-board";
|
||||
|
||||
describe("resolveMobileBoardViewport", () => {
|
||||
test("keeps phones mobile regardless of their viewport width", () => {
|
||||
@@ -56,3 +60,26 @@ describe("resolveMobileBoardViewport", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveIsAutomaticMobileBoard", () => {
|
||||
test.each([
|
||||
[true, true, true],
|
||||
[true, false, false],
|
||||
[false, true, false],
|
||||
[false, false, false],
|
||||
])(
|
||||
"requires both a mobile viewport and the server setting (viewport: %s, setting: %s)",
|
||||
(isMobileViewport, enableAutomaticMobileLayout, expected) => {
|
||||
expect(resolveIsAutomaticMobileBoard({ isMobileViewport, enableAutomaticMobileLayout })).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
test("does not show the tablet resolution skeleton in legacy mode", () => {
|
||||
expect(
|
||||
shouldShowMobileBoardViewportSkeleton({
|
||||
enableAutomaticMobileLayout: false,
|
||||
isResolved: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { Box, Group, Skeleton, Stack } from "@mantine/core";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
export const mobileBoardMediaQuery = "(max-width: 48em)";
|
||||
|
||||
export type MobileBoardDeviceClass = "phone" | "tablet" | "desktop";
|
||||
@@ -70,6 +72,7 @@ export const MobileBoardViewportProvider = ({
|
||||
initialDeviceClass,
|
||||
children,
|
||||
}: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => {
|
||||
const { enableAutomaticMobileLayout } = useSettings();
|
||||
const [hasResolvedClientWidth, setHasResolvedClientWidth] = useState(false);
|
||||
const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", {
|
||||
getInitialValueInEffect: true,
|
||||
@@ -87,11 +90,42 @@ export const MobileBoardViewportProvider = ({
|
||||
|
||||
return (
|
||||
<MobileBoardViewportContext.Provider value={viewport}>
|
||||
{viewport.isResolved ? children : <MobileBoardViewportSkeleton />}
|
||||
{shouldShowMobileBoardViewportSkeleton({
|
||||
enableAutomaticMobileLayout,
|
||||
isResolved: viewport.isResolved,
|
||||
}) ? (
|
||||
<MobileBoardViewportSkeleton />
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</MobileBoardViewportContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useMobileBoardViewport = () => useContext(MobileBoardViewportContext);
|
||||
|
||||
export const useIsMobileBoard = () => useMobileBoardViewport().isMobile;
|
||||
export const resolveIsAutomaticMobileBoard = ({
|
||||
isMobileViewport,
|
||||
enableAutomaticMobileLayout,
|
||||
}: {
|
||||
isMobileViewport: boolean;
|
||||
enableAutomaticMobileLayout: boolean;
|
||||
}) => isMobileViewport && enableAutomaticMobileLayout;
|
||||
|
||||
export const shouldShowMobileBoardViewportSkeleton = ({
|
||||
enableAutomaticMobileLayout,
|
||||
isResolved,
|
||||
}: {
|
||||
enableAutomaticMobileLayout: boolean;
|
||||
isResolved: boolean;
|
||||
}) => enableAutomaticMobileLayout && !isResolved;
|
||||
|
||||
export const useIsMobileBoard = () => {
|
||||
const viewport = useMobileBoardViewport();
|
||||
const { enableAutomaticMobileLayout } = useSettings();
|
||||
|
||||
return resolveIsAutomaticMobileBoard({
|
||||
isMobileViewport: viewport.isMobile,
|
||||
enableAutomaticMobileLayout,
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { requestBoardEditAction } from "@homarr/boards/edit-mode";
|
||||
import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
|
||||
|
||||
import { LanguageCombobox } from "./language-combobox";
|
||||
@@ -16,7 +17,7 @@ export const CurrentLanguageCombobox = ({ width, withinPortal }: CurrentLanguage
|
||||
return (
|
||||
<LanguageCombobox
|
||||
value={currentLocale}
|
||||
onChange={changeLocale}
|
||||
onChange={(locale) => requestBoardEditAction(() => changeLocale(locale))}
|
||||
isPending={isPending}
|
||||
width={width}
|
||||
withinPortal={withinPortal}
|
||||
|
||||
@@ -21,9 +21,17 @@ interface UserAvatarMenuProps {
|
||||
children: ReactNode;
|
||||
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
|
||||
isDockerEnabled?: boolean;
|
||||
opened: boolean;
|
||||
onOpenChange: (opened: boolean) => void;
|
||||
}
|
||||
|
||||
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||
export const UserAvatarMenu = ({
|
||||
children,
|
||||
availableUpdates,
|
||||
isDockerEnabled,
|
||||
opened,
|
||||
onOpenChange,
|
||||
}: UserAvatarMenuProps) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const session = useSession();
|
||||
|
||||
@@ -40,7 +48,7 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }:
|
||||
|
||||
return (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
|
||||
+131
-22
@@ -1,6 +1,7 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { chromium, devices, expect } from "@playwright/test";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import SuperJSON from "superjson";
|
||||
import { stringify } from "superjson";
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -18,9 +19,34 @@ const adminCredentials = {
|
||||
|
||||
const boardName = "mobile-e2e";
|
||||
|
||||
const setAutomaticMobileLayoutAsync = async (page: Page, enabled: boolean) => {
|
||||
await page.goto(new URL("/manage/settings", page.url()).toString());
|
||||
const toggle = page.getByRole("switch", { name: "Use automatic mobile layout" });
|
||||
await expect(toggle).toBeVisible();
|
||||
|
||||
if ((await toggle.isChecked()) !== enabled) {
|
||||
if (enabled) {
|
||||
await toggle.check();
|
||||
} else {
|
||||
await toggle.uncheck();
|
||||
}
|
||||
|
||||
await toggle.locator("xpath=ancestor::form").getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(page.getByText("Settings saved successfully")).toBeVisible();
|
||||
}
|
||||
|
||||
await page.reload();
|
||||
if (enabled) {
|
||||
await expect(page.getByRole("switch", { name: "Use automatic mobile layout" })).toBeChecked();
|
||||
} else {
|
||||
await expect(page.getByRole("switch", { name: "Use automatic mobile layout" })).not.toBeChecked();
|
||||
}
|
||||
};
|
||||
|
||||
const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => {
|
||||
const boardId = createId();
|
||||
const layoutId = createId();
|
||||
const desktopLayoutId = createId();
|
||||
const mobileLayoutId = createId();
|
||||
const firstSectionId = createId();
|
||||
const secondSectionId = createId();
|
||||
|
||||
@@ -29,13 +55,22 @@ const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => {
|
||||
name: boardName,
|
||||
creatorId: userId,
|
||||
});
|
||||
await db.insert(sqliteSchema.layouts).values({
|
||||
id: layoutId,
|
||||
name: "Desktop",
|
||||
boardId,
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
});
|
||||
await db.insert(sqliteSchema.layouts).values([
|
||||
{
|
||||
id: desktopLayoutId,
|
||||
name: "Desktop",
|
||||
boardId,
|
||||
columnCount: 12,
|
||||
breakpoint: 1200,
|
||||
},
|
||||
{
|
||||
id: mobileLayoutId,
|
||||
name: "Mobile",
|
||||
boardId,
|
||||
columnCount: 4,
|
||||
breakpoint: 0,
|
||||
},
|
||||
]);
|
||||
await db.insert(sqliteSchema.sections).values([
|
||||
{
|
||||
id: firstSectionId,
|
||||
@@ -60,22 +95,33 @@ const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => {
|
||||
return {
|
||||
id,
|
||||
boardId,
|
||||
kind: index === 0 ? ("notebook" as const) : ("clock" as const),
|
||||
options: SuperJSON.stringify(index === 0 ? { content: "<p>Mobile notes</p>" } : {}),
|
||||
advancedOptions: SuperJSON.stringify({}),
|
||||
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({}),
|
||||
};
|
||||
});
|
||||
await db.insert(sqliteSchema.items).values(itemRows);
|
||||
await db.insert(sqliteSchema.itemLayouts).values(
|
||||
itemRows.map((item, index) => ({
|
||||
itemId: item.id,
|
||||
layoutId,
|
||||
sectionId: index < 18 ? firstSectionId : secondSectionId,
|
||||
xOffset: (index % 6) * 2,
|
||||
yOffset: Math.floor((index % 18) / 6) * 2,
|
||||
width: 2,
|
||||
height: 2,
|
||||
})),
|
||||
itemRows.flatMap((item, index) => [
|
||||
{
|
||||
itemId: item.id,
|
||||
layoutId: desktopLayoutId,
|
||||
sectionId: index < 18 ? firstSectionId : secondSectionId,
|
||||
xOffset: (index % 6) * 2,
|
||||
yOffset: Math.floor((index % 18) / 6) * 2,
|
||||
width: 2,
|
||||
height: 2,
|
||||
},
|
||||
{
|
||||
itemId: item.id,
|
||||
layoutId: mobileLayoutId,
|
||||
sectionId: index < 18 ? firstSectionId : secondSectionId,
|
||||
xOffset: (index % 2) * 2,
|
||||
yOffset: Math.floor((index % 18) / 2) * 2,
|
||||
width: 2,
|
||||
height: 2,
|
||||
},
|
||||
]),
|
||||
);
|
||||
await db
|
||||
.update(sqliteSchema.users)
|
||||
@@ -112,6 +158,7 @@ describe("Automatic mobile board", () => {
|
||||
await loginPage.locator("#password").fill(adminCredentials.password);
|
||||
await loginPage.locator("button[type='submit']").click();
|
||||
await loginPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 });
|
||||
await setAutomaticMobileLayoutAsync(loginPage, true);
|
||||
const storageState = await loginContext.storageState();
|
||||
|
||||
const mobileViewports = [
|
||||
@@ -150,6 +197,7 @@ describe("Automatic mobile board", () => {
|
||||
await expect(page.locator("[data-mobile-board-item]"), viewport.name).toHaveCount(36);
|
||||
await expect(page.locator(".grid-stack-item"), viewport.name).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /search/i }), viewport.name).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Edit item", exact: true }), viewport.name).toHaveCount(0);
|
||||
|
||||
const layoutMetrics = await page.evaluate(() => {
|
||||
const grids = Array.from(document.querySelectorAll<HTMLElement>("[data-mobile-board] > div"));
|
||||
@@ -201,6 +249,10 @@ describe("Automatic mobile board", () => {
|
||||
}
|
||||
|
||||
if (viewport.width === 390) {
|
||||
await page.getByRole("button", { name: `Current board: ${boardName}` }).click();
|
||||
await expect(page.getByRole("menuitem", { name: boardName })).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByRole("button", { name: "More" }).click();
|
||||
const moreDrawer = page.getByRole("dialog", { name: "More" });
|
||||
await expect(moreDrawer.getByRole("button", { name: "Second section" })).toBeVisible();
|
||||
@@ -209,12 +261,20 @@ describe("Automatic mobile board", () => {
|
||||
|
||||
const notebookActions = page.getByRole("button", { name: /Actions for Notebook/i });
|
||||
await notebookActions.click();
|
||||
await page.getByRole("button", { name: "Open widget details" }).click();
|
||||
await page.getByRole("button", { name: "Open widget details", exact: true }).click();
|
||||
const details = page.getByRole("dialog", { name: "Notebook" });
|
||||
await expect(details).toBeVisible();
|
||||
await expect(details.getByRole("button", { name: "Edit" })).toBeVisible();
|
||||
await details.getByRole("button", { name: "Close" }).click();
|
||||
await expect(notebookActions).toBeFocused();
|
||||
|
||||
const rssSummary = page.getByRole("button", { name: "Open widget details: RSS feeds" });
|
||||
await rssSummary.click();
|
||||
const rssDetails = page.getByRole("dialog", { name: "RSS feeds" });
|
||||
await expect(rssDetails).toBeVisible();
|
||||
await rssDetails.getByRole("button", { name: "Close" }).click();
|
||||
await expect(rssSummary).toBeFocused();
|
||||
|
||||
const rtlOverflow = await page.evaluate(() => {
|
||||
document.documentElement.dir = "rtl";
|
||||
return document.documentElement.scrollWidth - document.documentElement.clientWidth;
|
||||
@@ -233,7 +293,56 @@ describe("Automatic mobile board", () => {
|
||||
await desktopPage.goto(`${baseUrl}/boards/${boardName}`);
|
||||
await expect(desktopPage.locator("[data-mobile-board]")).toHaveCount(0);
|
||||
await expect(desktopPage.locator(".grid-stack-item")).toHaveCount(36);
|
||||
|
||||
await desktopPage.getByRole("button", { name: "Edit item", exact: true }).click();
|
||||
await desktopPage.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(desktopPage.locator("[data-mobile-board]")).toBeVisible();
|
||||
await desktopPage.getByRole("button", { name: "More" }).click();
|
||||
await desktopPage.getByRole("dialog", { name: "More" }).getByRole("link", { name: "Settings" }).click();
|
||||
const unsavedChangesDialog = desktopPage.getByRole("dialog", { name: "Unsaved changes" });
|
||||
await expect(unsavedChangesDialog).toBeVisible();
|
||||
await desktopPage.keyboard.press("Escape");
|
||||
await expect(unsavedChangesDialog).toBeHidden();
|
||||
await desktopPage.goBack();
|
||||
await expect(unsavedChangesDialog).toBeVisible();
|
||||
await desktopPage.goBack();
|
||||
await expect(desktopPage.getByRole("dialog", { name: "Unsaved changes" })).toHaveCount(1);
|
||||
await desktopPage.keyboard.press("Escape");
|
||||
await expect(unsavedChangesDialog).toBeHidden();
|
||||
await desktopPage.setViewportSize({ width: 800, height: 900 });
|
||||
await desktopPage.getByRole("button", { name: "Edit item", exact: true }).click();
|
||||
await expect(desktopPage.getByText("The board was successfully saved")).toBeVisible();
|
||||
await expect
|
||||
.poll(() => desktopPage.evaluate(() => window.history.state?.["__homarrBoardEditGuard"] ?? null))
|
||||
.toBeNull();
|
||||
|
||||
await setAutomaticMobileLayoutAsync(desktopPage, false);
|
||||
await desktopContext.close();
|
||||
|
||||
const legacyMobileContext = await browser.newContext({
|
||||
storageState,
|
||||
viewport: { width: 390, height: 844 },
|
||||
userAgent: devices["iPhone 13"].userAgent,
|
||||
hasTouch: true,
|
||||
isMobile: true,
|
||||
});
|
||||
const legacyMobilePage = await legacyMobileContext.newPage();
|
||||
await legacyMobilePage.goto(`${baseUrl}/boards/${boardName}`);
|
||||
await expect(legacyMobilePage.locator("[data-mobile-board]")).toHaveCount(0);
|
||||
await expect(legacyMobilePage.locator(".grid-stack-item")).toHaveCount(36);
|
||||
await expect(legacyMobilePage.getByRole("button", { name: "Edit item" })).toBeVisible();
|
||||
await expect(legacyMobilePage.getByRole("button", { name: "More" })).toHaveCount(0);
|
||||
await legacyMobilePage.getByRole("button", { name: "First section" }).click();
|
||||
await legacyMobilePage.getByRole("button", { name: "Second section" }).click();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
legacyMobilePage
|
||||
.locator(".grid-stack")
|
||||
.first()
|
||||
.evaluate((element) => getComputedStyle(element).getPropertyValue("--gridstack-column-count").trim()),
|
||||
)
|
||||
.toBe("4");
|
||||
await legacyMobileContext.close();
|
||||
} finally {
|
||||
await loginContext.close();
|
||||
await browser.close();
|
||||
|
||||
@@ -1913,9 +1913,6 @@ const getHomeIdBoardAsync = async (
|
||||
user: InferSelectModel<typeof users> | null,
|
||||
deviceType: DeviceType,
|
||||
) => {
|
||||
// Mobile home-board selection remains device-based because the server does
|
||||
// not know the resolved CSS viewport. Tablet rendering is resolved against
|
||||
// 48em on the client and must not be guessed here.
|
||||
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
|
||||
|
||||
if (!user) {
|
||||
@@ -1937,7 +1934,8 @@ const getHomeIdBoardAsync = async (
|
||||
and(
|
||||
eq(groupMembers.userId, user.id),
|
||||
not(eq(groups.name, everyoneGroup)),
|
||||
or(not(isNull(groups[settingKey])), not(isNull(groups.homeBoardId))),
|
||||
not(isNull(groups[settingKey])),
|
||||
not(isNull(groups.homeBoardId)),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(groups.position))
|
||||
|
||||
@@ -19,6 +19,7 @@ import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
const boardServerSettingsSchema = z.object({
|
||||
homeBoardId: z.string().nullable(),
|
||||
mobileHomeBoardId: z.string().nullable(),
|
||||
enableAutomaticMobileLayout: z.boolean(),
|
||||
enableStatusByDefault: z.boolean(),
|
||||
forceDisableStatus: z.boolean(),
|
||||
}) satisfies z.ZodType<ServerSettings["board"]>;
|
||||
@@ -39,7 +40,7 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get global board defaults, including desktop/mobile home board IDs and status behavior. Requires admin permission",
|
||||
"Get global board defaults, including desktop/mobile home board IDs, automatic mobile layout mode, and status behavior. Requires admin permission",
|
||||
},
|
||||
})
|
||||
.input(z.void())
|
||||
@@ -54,7 +55,7 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
|
||||
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableAutomaticMobileLayout, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
|
||||
},
|
||||
})
|
||||
.input(boardServerSettingsUpdateSchema)
|
||||
@@ -103,10 +104,19 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
|
||||
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
|
||||
const next = {
|
||||
...current,
|
||||
...input.value,
|
||||
} as ServerSettings[keyof ServerSettings]);
|
||||
} as ServerSettings[keyof ServerSettings];
|
||||
const existing = await ctx.db.query.serverSettings.findFirst({
|
||||
where: eq(serverSettings.settingKey, input.settingsKey),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, next);
|
||||
} else {
|
||||
await insertServerSettingByKeyAsync(ctx.db, input.settingsKey, next);
|
||||
}
|
||||
}),
|
||||
initSettings: onboardingProcedure
|
||||
.requiresStep("settings")
|
||||
|
||||
@@ -549,64 +549,6 @@ describe("getHomeBoard should return home board", () => {
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
|
||||
});
|
||||
test.each([
|
||||
{ deviceType: "mobile", expectedBoard: "mobile-home" },
|
||||
{ deviceType: "tablet", expectedBoard: "desktop-home" },
|
||||
] as const)(
|
||||
"should use the $expectedBoard preference for a $deviceType device",
|
||||
async ({ deviceType, expectedBoard }) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType, session: defaultSession });
|
||||
|
||||
const mobileBoardProps = await createFullBoardAsync(db, "mobile-home");
|
||||
const desktopBoardProps = await createFullBoardAsync(db, "desktop-home");
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
homeBoardId: desktopBoardProps.boardId,
|
||||
mobileHomeBoardId: mobileBoardProps.boardId,
|
||||
})
|
||||
.where(eq(users.id, defaultCreatorId));
|
||||
|
||||
// Act
|
||||
const result = await caller.getHomeBoard();
|
||||
|
||||
// Assert
|
||||
expectInputToBeFullBoardWithName(result, {
|
||||
...(expectedBoard === "mobile-home" ? mobileBoardProps : desktopBoardProps),
|
||||
name: expectedBoard,
|
||||
});
|
||||
},
|
||||
);
|
||||
test("should return a mobile-only group home board", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: "mobile", session: defaultSession });
|
||||
|
||||
const mobileBoardProps = await createFullBoardAsync(db, "group-mobile-home");
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "mobile group",
|
||||
position: 1,
|
||||
homeBoardId: null,
|
||||
mobileHomeBoardId: mobileBoardProps.boardId,
|
||||
});
|
||||
await db.insert(groupMembers).values({
|
||||
userId: defaultCreatorId,
|
||||
groupId,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await caller.getHomeBoard();
|
||||
|
||||
// Assert
|
||||
expectInputToBeFullBoardWithName(result, {
|
||||
name: "group-mobile-home",
|
||||
...mobileBoardProps,
|
||||
});
|
||||
});
|
||||
test("should return global home board when user doesn't have one", async () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
@@ -2077,12 +2019,9 @@ const expectInputToBeFullBoardWithName = (
|
||||
};
|
||||
|
||||
const createFullBoardAsync = async (db: Database, name: string) => {
|
||||
await db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: defaultCreatorId,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
await db.insert(users).values({
|
||||
id: defaultCreatorId,
|
||||
});
|
||||
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
|
||||
@@ -56,6 +56,33 @@ describe("getAll server settings", () => {
|
||||
});
|
||||
|
||||
describe("saveSettings", () => {
|
||||
test("saveSettings should persist settings when defaults are not seeded", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
await caller.saveSettings({
|
||||
settingsKey: "board",
|
||||
value: {
|
||||
enableAutomaticMobileLayout: false,
|
||||
},
|
||||
});
|
||||
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings).toStrictEqual([
|
||||
{
|
||||
settingKey: "board",
|
||||
value: stringify({
|
||||
...defaultServerSettings.board,
|
||||
enableAutomaticMobileLayout: false,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("saveSettings should update settings and return true when it updated only one", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
@@ -103,6 +130,27 @@ describe("board settings API", () => {
|
||||
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
|
||||
});
|
||||
|
||||
test("getBoardSettings should backfill the automatic layout default for legacy values", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
const legacyBoardSettings = {
|
||||
homeBoardId: defaultServerSettings.board.homeBoardId,
|
||||
mobileHomeBoardId: defaultServerSettings.board.mobileHomeBoardId,
|
||||
enableStatusByDefault: defaultServerSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: defaultServerSettings.board.forceDisableStatus,
|
||||
};
|
||||
await db.insert(serverSettings).values({
|
||||
settingKey: "board",
|
||||
value: stringify(legacyBoardSettings),
|
||||
});
|
||||
|
||||
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
|
||||
});
|
||||
|
||||
test("updateBoardSettings should insert settings when defaults are not persisted", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
@@ -145,6 +193,7 @@ describe("board settings API", () => {
|
||||
await caller.updateBoardSettings({ homeBoardId: boardId });
|
||||
const result = await caller.updateBoardSettings({
|
||||
mobileHomeBoardId: boardId,
|
||||
enableAutomaticMobileLayout: false,
|
||||
enableStatusByDefault: false,
|
||||
});
|
||||
|
||||
@@ -152,6 +201,7 @@ describe("board settings API", () => {
|
||||
...defaultServerSettings.board,
|
||||
homeBoardId: boardId,
|
||||
mobileHomeBoardId: boardId,
|
||||
enableAutomaticMobileLayout: false,
|
||||
enableStatusByDefault: false,
|
||||
});
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { settleIntegrationQueries } from "./settle-integrations";
|
||||
import { getIntegrationQueryProvenance, settleIntegrationQueries } from "./settle-integrations";
|
||||
|
||||
const integrations = [
|
||||
{ id: "first", name: "First", kind: "mock" },
|
||||
@@ -76,3 +76,19 @@ describe("settleIntegrationQueries", () => {
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIntegrationQueryProvenance", () => {
|
||||
it("counts dropped and stale integration results independently", () => {
|
||||
expect(getIntegrationQueryProvenance(4, [{ isStale: false }, { isStale: true }, { isStale: true }])).toEqual({
|
||||
failedIntegrationCount: 1,
|
||||
staleIntegrationCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not report failures for complete current results", () => {
|
||||
expect(getIntegrationQueryProvenance(2, [{ isStale: false }, {}])).toEqual({
|
||||
failedIntegrationCount: 0,
|
||||
staleIntegrationCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,19 @@ interface Options<TIntegration extends IntegrationLike, TResult> {
|
||||
throwOnAllFailures?: boolean;
|
||||
}
|
||||
|
||||
export interface IntegrationQueryProvenance {
|
||||
failedIntegrationCount: number;
|
||||
staleIntegrationCount: number;
|
||||
}
|
||||
|
||||
export const getIntegrationQueryProvenance = (
|
||||
selectedIntegrationCount: number,
|
||||
results: readonly { isStale?: boolean }[],
|
||||
): IntegrationQueryProvenance => ({
|
||||
failedIntegrationCount: Math.max(0, selectedIntegrationCount - results.length),
|
||||
staleIntegrationCount: results.filter((result) => result.isStale === true).length,
|
||||
});
|
||||
|
||||
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
|
||||
integrations: TIntegration[],
|
||||
fn: (integration: TIntegration) => Promise<TResult>,
|
||||
|
||||
@@ -67,6 +67,7 @@ const MCP_TOOL_ALLOWLIST = {
|
||||
"board_deleteBoard",
|
||||
"board_duplicateBoard",
|
||||
"board_renameBoard",
|
||||
"board_resetLayout",
|
||||
"board_savePartialBoardSettings",
|
||||
"board_setHomeBoard",
|
||||
"board_setMobileHomeBoard",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/settings": "workspace:^0.1.0",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
|
||||
import { getBoardLayouts, getCurrentLayout, getDesktopLayout } from "./context";
|
||||
|
||||
const createBoard = () =>
|
||||
({
|
||||
layouts: [
|
||||
{ id: "small", name: "Small", columnCount: 4, breakpoint: 0 },
|
||||
{ id: "medium", name: "Medium", columnCount: 8, breakpoint: 768 },
|
||||
{ id: "large", name: "Large", columnCount: 12, breakpoint: 1200 },
|
||||
],
|
||||
}) as RouterOutputs["board"]["getBoardByName"];
|
||||
|
||||
describe("board layout selection", () => {
|
||||
test("automatic mode always uses the desktop layout", () => {
|
||||
const board = createBoard();
|
||||
|
||||
expect(getDesktopLayout(board).id).toBe("large");
|
||||
expect(getCurrentLayout(board, true, 390)).toBe("large");
|
||||
});
|
||||
|
||||
test.each([
|
||||
[390, "small"],
|
||||
[768, "medium"],
|
||||
[1024, "medium"],
|
||||
[1440, "large"],
|
||||
])("legacy mode selects the matching responsive layout at %spx", (viewportWidth, expectedLayoutId) => {
|
||||
expect(getCurrentLayout(createBoard(), false, viewportWidth)).toBe(expectedLayoutId);
|
||||
});
|
||||
|
||||
test("keeps every saved layout available for reversible mutations", () => {
|
||||
expect(getBoardLayouts(createBoard())).toEqual(["small", "medium", "large"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { BoardEditAction, BoardEditActionEvent } from "./edit-mode";
|
||||
import { boardEditActionEventName, requestBoardEditAction } from "./edit-mode";
|
||||
|
||||
describe("requestBoardEditAction", () => {
|
||||
test("runs the action immediately when no edit guard is mounted", () => {
|
||||
const action = vi.fn();
|
||||
|
||||
requestBoardEditAction(action);
|
||||
|
||||
expect(action).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("lets an edit guard defer the action until the user confirms", () => {
|
||||
const action = vi.fn();
|
||||
let deferredAction: BoardEditAction | undefined;
|
||||
const listener = (event: Event) => {
|
||||
event.preventDefault();
|
||||
deferredAction = (event as BoardEditActionEvent).detail.action;
|
||||
};
|
||||
document.addEventListener(boardEditActionEventName, listener, { once: true });
|
||||
|
||||
requestBoardEditAction(action);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
|
||||
void deferredAction?.();
|
||||
expect(action).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,28 @@ 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
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { stringify } from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq } from "@homarr/db";
|
||||
import {
|
||||
getServerSettingByKeyAsync,
|
||||
getServerSettingsAsync,
|
||||
shouldEnableAutomaticMobileLayoutForUpgrade,
|
||||
} from "@homarr/db/queries";
|
||||
import { boards, layouts, serverSettings } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { defaultServerSettings } from "@homarr/server-settings";
|
||||
|
||||
import { seedServerSettingsAsync } from "./seed";
|
||||
|
||||
describe("automatic mobile layout upgrade default", () => {
|
||||
test("enables automatic mode for a new instance", () => {
|
||||
expect(shouldEnableAutomaticMobileLayoutForUpgrade([])).toBe(true);
|
||||
});
|
||||
|
||||
test("enables automatic mode when every existing board has only its canonical layout", () => {
|
||||
expect(
|
||||
shouldEnableAutomaticMobileLayoutForUpgrade([
|
||||
{ boardId: "first", name: "Base", breakpoint: 0 },
|
||||
{ boardId: "second", name: "Base", breakpoint: 0 },
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves legacy mode when a board has multiple responsive layouts", () => {
|
||||
expect(
|
||||
shouldEnableAutomaticMobileLayoutForUpgrade([
|
||||
{ boardId: "board", name: "Base", breakpoint: 0 },
|
||||
{ boardId: "board", name: "Mobile", breakpoint: 480 },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ boardId: "board", name: "Phone", breakpoint: 0 },
|
||||
{ boardId: "board", name: "Base", breakpoint: 480 },
|
||||
])("preserves legacy mode for a noncanonical saved layout", (existingLayout) => {
|
||||
expect(shouldEnableAutomaticMobileLayoutForUpgrade([existingLayout])).toBe(false);
|
||||
});
|
||||
|
||||
test("uses the upgrade heuristic when migrations and seeding are skipped", async () => {
|
||||
const db = createDb();
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false });
|
||||
await db.insert(layouts).values([
|
||||
{ id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 },
|
||||
{ id: createId(), boardId, name: "Phone", columnCount: 4, breakpoint: 480 },
|
||||
]);
|
||||
|
||||
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toMatchObject({
|
||||
enableAutomaticMobileLayout: false,
|
||||
});
|
||||
await expect(getServerSettingsAsync(db)).resolves.toMatchObject({
|
||||
board: {
|
||||
enableAutomaticMobileLayout: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("backfills legacy responsive instances once without overriding an explicit choice", async () => {
|
||||
const db = createDb();
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false });
|
||||
await db.insert(layouts).values([
|
||||
{ id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 },
|
||||
{ id: createId(), boardId, name: "Phone", columnCount: 4, breakpoint: 480 },
|
||||
]);
|
||||
await db.insert(serverSettings).values({
|
||||
settingKey: "board",
|
||||
value: stringify({
|
||||
homeBoardId: null,
|
||||
mobileHomeBoardId: null,
|
||||
enableStatusByDefault: true,
|
||||
forceDisableStatus: false,
|
||||
}),
|
||||
});
|
||||
|
||||
await seedServerSettingsAsync(db);
|
||||
await seedServerSettingsAsync(db);
|
||||
|
||||
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual({
|
||||
...defaultServerSettings.board,
|
||||
enableAutomaticMobileLayout: false,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(serverSettings)
|
||||
.set({
|
||||
value: stringify({
|
||||
...defaultServerSettings.board,
|
||||
enableAutomaticMobileLayout: true,
|
||||
}),
|
||||
})
|
||||
.where(eq(serverSettings.settingKey, "board"));
|
||||
|
||||
await seedServerSettingsAsync(db);
|
||||
|
||||
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual(defaultServerSettings.board);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
|
||||
import {
|
||||
getServerSettingByKeyAsync,
|
||||
insertServerSettingByKeyAsync,
|
||||
shouldEnableAutomaticMobileLayoutForUpgrade,
|
||||
updateServerSettingByKeyAsync,
|
||||
} from "../queries/server-setting";
|
||||
|
||||
@@ -155,19 +156,33 @@ const seedDefaultSearchEnginesAsync = async (db: Database) => {
|
||||
console.log("Set Homarr docs as the default search engine");
|
||||
};
|
||||
|
||||
const seedServerSettingsAsync = async (db: Database) => {
|
||||
export const seedServerSettingsAsync = async (db: Database) => {
|
||||
const serverSettingsData = await db.query.serverSettings.findMany();
|
||||
const existingLayouts = await db.query.layouts.findMany({
|
||||
columns: {
|
||||
boardId: true,
|
||||
name: true,
|
||||
breakpoint: true,
|
||||
},
|
||||
});
|
||||
const defaultsForThisInstance = {
|
||||
...defaultServerSettings,
|
||||
board: {
|
||||
...defaultServerSettings.board,
|
||||
enableAutomaticMobileLayout: shouldEnableAutomaticMobileLayoutForUpgrade(existingLayouts),
|
||||
},
|
||||
};
|
||||
|
||||
for (const settingsKey of defaultServerSettingsKeys) {
|
||||
const currentDbEntry = serverSettingsData.find((setting) => setting.settingKey === settingsKey);
|
||||
if (!currentDbEntry) {
|
||||
await insertServerSettingByKeyAsync(db, settingsKey, defaultServerSettings[settingsKey]);
|
||||
await insertServerSettingByKeyAsync(db, settingsKey, defaultsForThisInstance[settingsKey]);
|
||||
console.log(`Created serverSetting through seed key=${settingsKey}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentSettings = await getServerSettingByKeyAsync(db, settingsKey);
|
||||
const defaultSettings = defaultServerSettings[settingsKey];
|
||||
const currentSettings = SuperJSON.parse<(typeof defaultServerSettings)[typeof settingsKey]>(currentDbEntry.value);
|
||||
const defaultSettings = defaultsForThisInstance[settingsKey];
|
||||
const missingKeys = objectKeys(defaultSettings).filter((key) => !(key in currentSettings));
|
||||
|
||||
if (missingKeys.length === 0) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import SuperJSON from "superjson";
|
||||
import { parse, stringify } from "superjson";
|
||||
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
@@ -7,23 +7,71 @@ import type { Database } from "..";
|
||||
import { eq } from "..";
|
||||
import { serverSettings } from "../schema";
|
||||
|
||||
interface ExistingBoardLayout {
|
||||
boardId: string;
|
||||
name: string;
|
||||
breakpoint: number;
|
||||
}
|
||||
|
||||
export const shouldEnableAutomaticMobileLayoutForUpgrade = (existingLayouts: ExistingBoardLayout[]) => {
|
||||
const layoutCounts = new Map<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();
|
||||
|
||||
return defaultServerSettingsKeys.reduce((acc, settingKey) => {
|
||||
const setting = settings.find((setting) => setting.settingKey === settingKey);
|
||||
const mergedSettings = defaultServerSettingsKeys.reduce((acc, settingKey) => {
|
||||
const setting = settings.find((candidate) => candidate.settingKey === settingKey);
|
||||
if (!setting) {
|
||||
// Typescript is not happy because the key is a union and it does not know that they are the same
|
||||
acc[settingKey] = defaultServerSettings[settingKey] as never;
|
||||
acc[settingKey] = { ...defaultServerSettings[settingKey] } as never;
|
||||
return acc;
|
||||
}
|
||||
|
||||
const persistedSettings = parse<Record<string, unknown>>(setting.value);
|
||||
acc[settingKey] = {
|
||||
...defaultServerSettings[settingKey],
|
||||
...SuperJSON.parse(setting.value),
|
||||
};
|
||||
...persistedSettings,
|
||||
} as never;
|
||||
return acc;
|
||||
}, {} as ServerSettings);
|
||||
|
||||
const persistedBoardSettings = settings.find((setting) => setting.settingKey === "board");
|
||||
const parsedBoardSettings = persistedBoardSettings
|
||||
? parse<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) => {
|
||||
@@ -32,10 +80,31 @@ 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];
|
||||
}
|
||||
|
||||
return SuperJSON.parse<ServerSettings[TKey]>(dbSettings.value);
|
||||
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;
|
||||
};
|
||||
|
||||
export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
|
||||
@@ -46,7 +115,7 @@ export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSet
|
||||
await db
|
||||
.update(serverSettings)
|
||||
.set({
|
||||
value: SuperJSON.stringify(value),
|
||||
value: stringify(value),
|
||||
})
|
||||
.where(eq(serverSettings.settingKey, key));
|
||||
};
|
||||
@@ -58,6 +127,6 @@ export const insertServerSettingByKeyAsync = async <TKey extends keyof ServerSet
|
||||
) => {
|
||||
await db.insert(serverSettings).values({
|
||||
settingKey: key,
|
||||
value: SuperJSON.stringify(value),
|
||||
value: stringify(value),
|
||||
});
|
||||
};
|
||||
@@ -16,6 +16,7 @@ export interface ConfirmModalProps {
|
||||
children: ReactNode;
|
||||
onConfirm?: () => MaybePromise<void>;
|
||||
onCancel?: () => MaybePromise<void>;
|
||||
onClose?: () => void;
|
||||
closeOnConfirm?: boolean;
|
||||
closeOnCancel?: boolean;
|
||||
cancelProps?: ButtonProps & ComponentPropsWithoutRef<"button">;
|
||||
|
||||
@@ -128,6 +128,7 @@ interface OpenModalOptions {
|
||||
keepMounted?: boolean;
|
||||
onClose?: () => void;
|
||||
title?: stringOrTranslation;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const useModalAction = <TModal extends ModalDefinition>(modal: TModal) => {
|
||||
@@ -148,6 +149,10 @@ export const useConfirmModal = () => {
|
||||
const { openModal } = useModalAction(ConfirmModal);
|
||||
|
||||
return {
|
||||
openConfirmModal: (props: ConfirmModalProps) => openModal(props, { title: props.title }),
|
||||
openConfirmModal: (props: ConfirmModalProps) =>
|
||||
openModal(props, {
|
||||
title: props.title,
|
||||
onClose: props.onClose,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -27,6 +27,7 @@ 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"], "enableStatusByDefault" | "forceDisableStatus"> &
|
||||
Pick<ServerSettings["board"], "enableAutomaticMobileLayout" | "enableStatusByDefault" | "forceDisableStatus"> &
|
||||
Pick<ServerSettings["user"], "enableGravatar">;
|
||||
|
||||
export interface PublicServerSettings {
|
||||
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
|
||||
board: Pick<
|
||||
ServerSettings["board"],
|
||||
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
|
||||
"homeBoardId" | "mobileHomeBoardId" | "enableAutomaticMobileLayout" | "enableStatusByDefault" | "forceDisableStatus"
|
||||
>;
|
||||
user: Pick<ServerSettings["user"], "enableGravatar">;
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export const createSettings = ({
|
||||
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
|
||||
pingIconsEnabled: user?.pingIconsEnabled ?? false,
|
||||
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
|
||||
enableAutomaticMobileLayout: serverSettings.board.enableAutomaticMobileLayout,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
enableGravatar: serverSettings.user.enableGravatar,
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"dependencies": {
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/boards": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { DayOfWeek } from "@mantine/dates";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { requestBoardEditAction } from "@homarr/boards/edit-mode";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
@@ -160,7 +161,7 @@ export const useUserPreferences = () => {
|
||||
|
||||
const clientSetters: Partial<Record<UserPreferenceKey, (value: unknown) => void>> = {
|
||||
colorScheme: (value) => setColorScheme(value as ColorScheme),
|
||||
locale: (value) => changeLocale(value as typeof currentLocale),
|
||||
locale: (value) => requestBoardEditAction(() => changeLocale(value as typeof currentLocale)),
|
||||
fullPreferencesPage: () => undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function BookmarksWidget({
|
||||
return (
|
||||
<Stack h="100%" mih={0} gap="sm" p={height < 120 ? "xs" : "sm"}>
|
||||
{options.title.length > 0 && (
|
||||
<Title order={4} px="0.25rem" lineClamp={displayMode === "advanced" ? undefined : 1}>
|
||||
<Title order={4} px="0.25rem" lineClamp={1}>
|
||||
{options.title}
|
||||
</Title>
|
||||
)}
|
||||
@@ -283,14 +283,14 @@ const AdvancedBookmarksLayout = ({
|
||||
style={{ width: 40, height: 40, flex: "0 0 auto" }}
|
||||
/>
|
||||
<Stack gap={3} miw={0}>
|
||||
<Text fw={700} size="sm">
|
||||
<Text fw={700} size="sm" truncate="end">
|
||||
{app.name}
|
||||
</Text>
|
||||
<Anchor component="span" size="xs" truncate="end">
|
||||
{getBookmarkHostname(app.href)}
|
||||
</Anchor>
|
||||
{app.description && (
|
||||
<Text size="xs" c="dimmed">
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{app.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Center, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface WidgetQueryProvenance {
|
||||
error?: unknown;
|
||||
failedIntegrationCount?: number;
|
||||
staleIntegrationCount?: number;
|
||||
expectedIntegrationCount?: number;
|
||||
receivedIntegrationCount?: number;
|
||||
}
|
||||
|
||||
export const hasWidgetDataWarning = ({
|
||||
error,
|
||||
failedIntegrationCount = 0,
|
||||
staleIntegrationCount = 0,
|
||||
expectedIntegrationCount,
|
||||
receivedIntegrationCount,
|
||||
}: WidgetQueryProvenance) =>
|
||||
Boolean(error) ||
|
||||
failedIntegrationCount > 0 ||
|
||||
staleIntegrationCount > 0 ||
|
||||
(expectedIntegrationCount !== undefined &&
|
||||
receivedIntegrationCount !== undefined &&
|
||||
receivedIntegrationCount < expectedIntegrationCount);
|
||||
|
||||
export const throwOnInitialQueryError = (error: unknown, hasData: boolean) => {
|
||||
if (error && !hasData) throw error;
|
||||
};
|
||||
|
||||
interface WidgetDataStateProps {
|
||||
children: ReactNode;
|
||||
hasWarning: boolean;
|
||||
}
|
||||
|
||||
export function WidgetDataState({ children, hasWarning }: WidgetDataStateProps) {
|
||||
const t = useI18n();
|
||||
const label = t("board.mobile.dataWarning");
|
||||
|
||||
return (
|
||||
<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 },
|
||||
mobile: { supportsDetailView: true, unmountWhenOffscreen: 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 },
|
||||
mobile: { supportsDetailView: true, unmountWhenOffscreen: true },
|
||||
queryKey: [["widget", "dnsHole"]],
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
|
||||
@@ -10,6 +10,7 @@ export const widgetKind = "dnsHoleSummary";
|
||||
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconAd,
|
||||
mobile: { unmountWhenOffscreen: true },
|
||||
queryKey: [["widget", "dnsHole"]],
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
|
||||
Generated
+6
@@ -1574,6 +1574,9 @@ importers:
|
||||
'@homarr/api':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../api
|
||||
'@homarr/settings':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../settings
|
||||
react:
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.7
|
||||
@@ -2572,6 +2575,9 @@ importers:
|
||||
'@homarr/auth':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../auth
|
||||
'@homarr/boards':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../boards
|
||||
'@homarr/common':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../common
|
||||
|
||||
Reference in new issue
Block a user