Revert "feat: refine mobile dashboard experience"
This reverts commit 315520beca.
This commit is contained in:
1 parent
8ac35083e6
commit
85db534fa7
35 files changed
+110
-989
No files matched your search
@@ -44,7 +44,7 @@ The main canvas and enabled rails stay at least one viewport tall, and the canva
|
||||
### Public
|
||||
|
||||
This will enable public access for everyone - regardless of whether they are logged in or not.
|
||||
This can be really useful if you want to have public boards in your company or for family and fiends.
|
||||
This can be really useful if you want to have public boards in your company or for family and friends.
|
||||
Modifications for public boards must be allowed explicitly using user groups and their permissions.
|
||||
|
||||
## Board settings
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { shouldPrefetchWidgetForRequest } from "./_prefetch-policy";
|
||||
|
||||
describe("shouldPrefetchWidgetForRequest", () => {
|
||||
test.each(["mobile", "tablet"])("defers downloads for %s requests", (deviceType) => {
|
||||
expect(shouldPrefetchWidgetForRequest("downloads", deviceType)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps downloads prefetched for desktop requests", () => {
|
||||
expect(shouldPrefetchWidgetForRequest("downloads", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test.each(["app", "bookmarks"] as const)("keeps eager %s data prefetched on mobile", (kind) => {
|
||||
expect(shouldPrefetchWidgetForRequest(kind, "mobile")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
export const shouldPrefetchWidgetForRequest = (kind: WidgetKind, deviceType: string | undefined) => {
|
||||
const isMobileDevice = deviceType === "mobile" || deviceType === "tablet";
|
||||
|
||||
return kind !== "downloads" || !isMobileDevice;
|
||||
};
|
||||
@@ -8,22 +8,12 @@ import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useIsMobileBoard } from "~/components/board/use-mobile-board";
|
||||
import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { BoardLogo } from "~/components/layout/logo/board-logo";
|
||||
|
||||
export const BoardSwitcherLogo = () => {
|
||||
const board = useRequiredBoard();
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||
const t = useI18n();
|
||||
const isMobileBoard = useIsMobileBoard();
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isMobileBoard });
|
||||
|
||||
if (!isMobileBoard) {
|
||||
return (
|
||||
<UnstyledButton component={Link} href="/">
|
||||
<BoardLogoWithTitle size="md" hideTitleOnMobile />
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu position="bottom-start" width={280}>
|
||||
@@ -36,7 +26,7 @@ export const BoardSwitcherLogo = () => {
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<BoardLogo size={28} />
|
||||
<Text fw={600} truncate title={board.name}>
|
||||
<Text fw={600} truncate>
|
||||
{board.name}
|
||||
</Text>
|
||||
<IconChevronDown size={16} aria-hidden />
|
||||
@@ -56,9 +46,7 @@ export const BoardSwitcherLogo = () => {
|
||||
leftSection={isCurrent ? <IconCheck size={20} /> : <IconLayoutBoard size={20} />}
|
||||
aria-current={isCurrent ? "page" : undefined}
|
||||
>
|
||||
<Text truncate title={availableBoard.name}>
|
||||
{availableBoard.name}
|
||||
</Text>
|
||||
{availableBoard.name}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getMobilePreloadRootMargin, mobilePreloadViewportMultiplier } from "./deferred-mobile-item";
|
||||
|
||||
describe("getMobilePreloadRootMargin", () => {
|
||||
test.each([
|
||||
[568, "1136px 0px"],
|
||||
[844, "1688px 0px"],
|
||||
[932, "1864px 0px"],
|
||||
])("preloads %i px viewports using vertical pixels", (viewportHeight, expected) => {
|
||||
expect(getMobilePreloadRootMargin(viewportHeight)).toBe(expected);
|
||||
});
|
||||
|
||||
test("keeps the preload policy explicit and prevents negative margins", () => {
|
||||
expect(mobilePreloadViewportMultiplier).toBe(2);
|
||||
expect(getMobilePreloadRootMargin(-100)).toBe("0px 0px");
|
||||
});
|
||||
});
|
||||
@@ -8,25 +8,14 @@ import classes from "./mobile-board.module.css";
|
||||
|
||||
interface DeferredMobileItemProps extends PropsWithChildren {
|
||||
eager: boolean;
|
||||
unmountWhenOffscreen: boolean;
|
||||
}
|
||||
|
||||
export const mobilePreloadViewportMultiplier = 2;
|
||||
|
||||
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, 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);
|
||||
return;
|
||||
}
|
||||
if (eager || hasMounted) return;
|
||||
|
||||
const element = wrapperRef.current;
|
||||
if (!element || typeof IntersectionObserver === "undefined") {
|
||||
@@ -34,37 +23,17 @@ export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, children }: De
|
||||
return;
|
||||
}
|
||||
|
||||
let observer: IntersectionObserver | null = null;
|
||||
const observe = () => {
|
||||
if (hasMountedOnceRef.current && !unmountWhenOffscreen) return;
|
||||
observer?.disconnect();
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
const isNearViewport = entry?.isIntersecting ?? false;
|
||||
|
||||
if (isNearViewport) {
|
||||
hasMountedOnceRef.current = true;
|
||||
setHasMounted(true);
|
||||
if (!unmountWhenOffscreen) observer?.disconnect();
|
||||
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]);
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry?.isIntersecting) return;
|
||||
setHasMounted(true);
|
||||
observer.disconnect();
|
||||
},
|
||||
{ rootMargin: "200% 0px" },
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [eager, hasMounted]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={classes.deferredItem} aria-busy={!hasMounted}>
|
||||
|
||||
@@ -6,18 +6,6 @@
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.boardTitle {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
|
||||
@@ -30,6 +18,8 @@
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 12rem;
|
||||
}
|
||||
|
||||
.deferredItem {
|
||||
@@ -44,18 +34,9 @@
|
||||
}
|
||||
|
||||
.sectionHeading {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
scroll-margin-top: calc(var(--app-shell-header-height) + var(--mantine-spacing-md));
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
color: var(--mantine-color-text);
|
||||
background: var(--mantine-color-body);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
box-shadow: var(--mantine-shadow-xs);
|
||||
padding-block: var(--mantine-spacing-xs);
|
||||
outline-offset: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.empty {
|
||||
@@ -67,21 +48,14 @@
|
||||
position: absolute;
|
||||
z-index: 11;
|
||||
top: 4px;
|
||||
inset-inline-end: 4px;
|
||||
right: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--mantine-color-white) 30%, transparent);
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
}
|
||||
|
||||
.detailContent,
|
||||
.detailContent > :global(.grid-stack-item-content) {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 24rem;
|
||||
}
|
||||
|
||||
.detailContent :global(.mantine-ActionIcon-root),
|
||||
.detailContent :global(.mantine-Button-root) {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { widgetImports } from "@homarr/widgets";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { useBoardPermissions } from "../permissions/client";
|
||||
import { SectionProvider } from "../sections/section-context";
|
||||
import { DeferredMobileItem } from "./deferred-mobile-item";
|
||||
import classes from "./mobile-board.module.css";
|
||||
@@ -71,16 +70,19 @@ 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}>
|
||||
<BoardItemContent
|
||||
item={item}
|
||||
displayMode={presentation.displayMode}
|
||||
disableContextMenu
|
||||
isReadOnly
|
||||
widgetStateRef={widgetStateRef}
|
||||
/>
|
||||
<MobileWidgetActions
|
||||
item={item}
|
||||
supportsDetails={presentation.supportsDetails}
|
||||
widgetStateRef={widgetStateRef}
|
||||
/>
|
||||
</DeferredMobileItem>
|
||||
<MobileWidgetActions item={item} supportsDetails={presentation.supportsDetails} widgetStateRef={widgetStateRef} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -88,7 +90,6 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => {
|
||||
export const MobileBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const desktopLayout = getDesktopLayout(board);
|
||||
const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]);
|
||||
const elements = useMemo(() => createMobileBoardElements(board, desktopLayout.id), [board, desktopLayout.id]);
|
||||
@@ -109,9 +110,6 @@ export const MobileBoard = () => {
|
||||
}}
|
||||
>
|
||||
<Box className={classes.stream} data-mobile-board>
|
||||
<Title order={1} className={classes.boardTitle}>
|
||||
{board.name}
|
||||
</Title>
|
||||
{elements.length === 0 ? (
|
||||
<Center className={classes.empty}>
|
||||
<Stack align="center" gap="xs" ta="center" maw={360}>
|
||||
@@ -122,7 +120,7 @@ export const MobileBoard = () => {
|
||||
{t("board.mobile.empty.title")}
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(hasChangeAccess ? "board.mobile.empty.editorDescription" : "board.mobile.empty.viewerDescription")}
|
||||
{t("board.mobile.empty.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
@@ -132,12 +130,10 @@ export const MobileBoard = () => {
|
||||
<Title
|
||||
key={block.id}
|
||||
id={block.anchorId}
|
||||
order={block.headingLevel}
|
||||
size={block.headingLevel === 2 ? "h4" : block.headingLevel === 3 ? "h5" : "h6"}
|
||||
fw={block.headingLevel === 2 ? 700 : 600}
|
||||
order={2}
|
||||
size="h4"
|
||||
className={classes.sectionHeading}
|
||||
tabIndex={-1}
|
||||
style={{ marginInlineStart: Math.max(0, block.headingLevel - 2) * 12 }}
|
||||
>
|
||||
{block.title}
|
||||
</Title>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { hasOpenDialogOutside } from "./mobile-dialog-stack";
|
||||
|
||||
describe("hasOpenDialogOutside", () => {
|
||||
test("detects an open child dialog outside the details portal", () => {
|
||||
const root = document.createElement("div");
|
||||
const details = document.createElement("div");
|
||||
const child = document.createElement("div");
|
||||
details.setAttribute("role", "dialog");
|
||||
child.setAttribute("role", "dialog");
|
||||
root.append(details, child);
|
||||
|
||||
expect(hasOpenDialogOutside(details, root)).toBe(true);
|
||||
});
|
||||
|
||||
test("ignores hidden and closed dialogs", () => {
|
||||
const root = document.createElement("div");
|
||||
const details = document.createElement("div");
|
||||
const hiddenChild = document.createElement("div");
|
||||
details.setAttribute("role", "dialog");
|
||||
hiddenChild.setAttribute("role", "dialog");
|
||||
hiddenChild.setAttribute("aria-hidden", "true");
|
||||
root.append(details, hiddenChild);
|
||||
|
||||
expect(hasOpenDialogOutside(details, root)).toBe(false);
|
||||
});
|
||||
|
||||
test("does not treat the current details dialog as nested", () => {
|
||||
const details = document.createElement("div");
|
||||
details.setAttribute("role", "dialog");
|
||||
|
||||
expect(hasOpenDialogOutside(details, details)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const getDialogElement = (element: HTMLElement | null) => {
|
||||
if (!element) return null;
|
||||
if (element.matches('[role="dialog"]')) return element;
|
||||
return element.closest<HTMLElement>('[role="dialog"]') ?? element.querySelector<HTMLElement>('[role="dialog"]');
|
||||
};
|
||||
|
||||
const isOpenDialog = (element: Element) =>
|
||||
element.getAttribute("aria-hidden") !== "true" &&
|
||||
element.getAttribute("data-hidden") !== "true" &&
|
||||
element.getAttribute("data-state") !== "closed" &&
|
||||
!element.hasAttribute("hidden");
|
||||
|
||||
export const hasOpenDialogOutside = (element: HTMLElement | null, root: ParentNode = document) => {
|
||||
const currentDialog = getDialogElement(element);
|
||||
|
||||
return Array.from(root.querySelectorAll('[role="dialog"]')).some(
|
||||
(dialog) => dialog !== currentDialog && isOpenDialog(dialog),
|
||||
);
|
||||
};
|
||||
|
||||
export const useHasNestedDialog = (opened: boolean, dialogRef: RefObject<HTMLDivElement | null>) => {
|
||||
const [hasNestedDialog, setHasNestedDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened || typeof MutationObserver === "undefined") return;
|
||||
|
||||
const update = () => setHasNestedDialog(hasOpenDialogOutside(dialogRef.current));
|
||||
update();
|
||||
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-hidden", "data-hidden", "data-state", "hidden"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [dialogRef, opened]);
|
||||
|
||||
return opened && hasNestedDialog;
|
||||
};
|
||||
@@ -147,10 +147,7 @@ describe("createMobileBoardItems", () => {
|
||||
type: "sectionHeading",
|
||||
sectionId: category.id,
|
||||
anchorId: getMobileSectionAnchorId(category.id),
|
||||
headingLevel: 2,
|
||||
});
|
||||
expect(result[2]).toMatchObject({ type: "sectionHeading", headingLevel: 3 });
|
||||
expect(result[4]).toMatchObject({ type: "sectionHeading", headingLevel: 4 });
|
||||
});
|
||||
|
||||
test("omits headings for empty and unnamed sections while preserving their items", () => {
|
||||
@@ -183,31 +180,4 @@ describe("createMobileBoardItems", () => {
|
||||
expect(result.map((element) => element.type)).toEqual(["item", "item"]);
|
||||
expect(result.map((element) => element.id)).toEqual(["inside-unnamed", "orphan"]);
|
||||
});
|
||||
|
||||
test("does not skip heading levels when unnamed sections are flattened", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const desktopLayoutId = board.layouts.at(0)?.id;
|
||||
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
|
||||
|
||||
const unnamedCategory = new CategorySectionMockBuilder({ id: "category", name: " " }).build();
|
||||
const namedDynamicSection = new DynamicSectionMockBuilder({
|
||||
id: "dynamic",
|
||||
options: { title: "Services", borderColor: "", customCssClasses: [] },
|
||||
})
|
||||
.addLayout({ layoutId: desktopLayoutId, parentSectionId: unnamedCategory.id })
|
||||
.build();
|
||||
|
||||
board.sections.push(unnamedCategory, namedDynamicSection);
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "item" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: namedDynamicSection.id })
|
||||
.build(),
|
||||
);
|
||||
|
||||
expect(createMobileBoardElements(board, desktopLayoutId)[0]).toMatchObject({
|
||||
type: "sectionHeading",
|
||||
title: "Services",
|
||||
headingLevel: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,6 @@ export type MobileBoardSectionHeading = {
|
||||
sectionId: string;
|
||||
title: string;
|
||||
anchorId: string;
|
||||
headingLevel: 2 | 3 | 4 | 5 | 6;
|
||||
};
|
||||
|
||||
export type MobileBoardElement = SectionItem | MobileBoardSectionHeading;
|
||||
@@ -52,10 +51,7 @@ const getLayout = <TLayout extends PositionedLayout>(
|
||||
(layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1),
|
||||
)[0];
|
||||
|
||||
const createSectionHeading = (
|
||||
section: CategorySection | DynamicSectionItem,
|
||||
headingDepth: number,
|
||||
): MobileBoardSectionHeading | null => {
|
||||
const createSectionHeading = (section: CategorySection | DynamicSectionItem): MobileBoardSectionHeading | null => {
|
||||
const title = (section.kind === "category" ? section.name : section.options.title).trim();
|
||||
if (title.length === 0) return null;
|
||||
|
||||
@@ -65,7 +61,6 @@ const createSectionHeading = (
|
||||
sectionId: section.id,
|
||||
title,
|
||||
anchorId: getMobileSectionAnchorId(section.id),
|
||||
headingLevel: Math.min(headingDepth + 2, 6) as MobileBoardSectionHeading["headingLevel"],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -117,18 +112,16 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string)
|
||||
|
||||
const createSectionElements = (
|
||||
section: CategorySection | DynamicSectionItem | (typeof rootSections)[number],
|
||||
headingDepth = 0,
|
||||
): { elements: MobileBoardElement[]; items: SectionItem[] } => {
|
||||
if (visitedSections.has(section.id)) return { elements: [], items: [] };
|
||||
const sectionId = section.id;
|
||||
visitedSections.add(sectionId);
|
||||
const heading = section.kind === "empty" ? null : createSectionHeading(section, headingDepth);
|
||||
|
||||
const sectionElements: MobileBoardElement[] = [];
|
||||
const sectionItems: SectionItem[] = [];
|
||||
for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) {
|
||||
if (element.type === "section") {
|
||||
const nestedResult = createSectionElements(element, heading ? headingDepth + 1 : headingDepth);
|
||||
const nestedResult = createSectionElements(element);
|
||||
sectionElements.push(...nestedResult.elements);
|
||||
sectionItems.push(...nestedResult.items);
|
||||
continue;
|
||||
@@ -143,6 +136,7 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string)
|
||||
|
||||
if (sectionItems.length === 0) return { elements: [], items: [] };
|
||||
|
||||
const heading = section.kind === "empty" ? null : createSectionHeading(section);
|
||||
return {
|
||||
elements: heading ? [heading, ...sectionElements] : sectionElements,
|
||||
items: sectionItems,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
isMobileContextActionVisible,
|
||||
resolveMobileItemPresentation,
|
||||
shouldRenderMobileWidgetActions,
|
||||
} from "./mobile-presentation";
|
||||
import { resolveMobileItemPresentation } from "./mobile-presentation";
|
||||
|
||||
describe("resolveMobileItemPresentation", () => {
|
||||
test("renders apps as eager compact tiles", () => {
|
||||
@@ -13,51 +9,19 @@ describe("resolveMobileItemPresentation", () => {
|
||||
height: 1,
|
||||
eager: true,
|
||||
supportsDetails: false,
|
||||
unmountWhenOffscreen: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("defaults widgets to full width without inventing a detail view", () => {
|
||||
test("defaults widgets to full width and clamps desktop height", () => {
|
||||
expect(resolveMobileItemPresentation({ kind: "calendar", height: 8 }, undefined)).toMatchObject({
|
||||
width: 2,
|
||||
height: 3,
|
||||
eager: false,
|
||||
supportsDetails: false,
|
||||
unmountWhenOffscreen: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("enables details only when the widget explicitly supports them", () => {
|
||||
expect(
|
||||
resolveMobileItemPresentation(
|
||||
{ kind: "calendar", height: 2 },
|
||||
{
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
supportsDetails: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps automatic footprint defaults when only details are configured", () => {
|
||||
expect(
|
||||
resolveMobileItemPresentation(
|
||||
{ kind: "calendar", height: 7 },
|
||||
{
|
||||
supportsDetailView: true,
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
width: 2,
|
||||
height: 3,
|
||||
supportsDetails: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("prefers explicit presentation metadata while eager mounting takes precedence", () => {
|
||||
test("prefers explicit presentation metadata", () => {
|
||||
expect(
|
||||
resolveMobileItemPresentation(
|
||||
{ kind: "clock", height: 3 },
|
||||
@@ -67,7 +31,6 @@ describe("resolveMobileItemPresentation", () => {
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: false,
|
||||
eager: true,
|
||||
unmountWhenOffscreen: true,
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
@@ -76,37 +39,6 @@ describe("resolveMobileItemPresentation", () => {
|
||||
displayMode: "mobileSummary",
|
||||
supportsDetails: false,
|
||||
eager: true,
|
||||
unmountWhenOffscreen: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRenderMobileWidgetActions", () => {
|
||||
test("does not render an action trigger without an explicit capability", () => {
|
||||
expect(
|
||||
shouldRenderMobileWidgetActions({
|
||||
supportsDetails: false,
|
||||
supportsRefresh: false,
|
||||
visibleContextActionCount: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ supportsDetails: true, supportsRefresh: false, visibleContextActionCount: 0 },
|
||||
{ supportsDetails: false, supportsRefresh: true, visibleContextActionCount: 0 },
|
||||
{ supportsDetails: false, supportsRefresh: false, visibleContextActionCount: 1 },
|
||||
])("renders an action trigger for an explicit capability", (capabilities) => {
|
||||
expect(shouldRenderMobileWidgetActions(capabilities)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMobileContextActionVisible", () => {
|
||||
test("requires actions to opt into the read-only mobile surface", () => {
|
||||
const action = { key: "configure", label: "Configure", onClick: () => undefined };
|
||||
|
||||
expect(isMobileContextActionVisible(action)).toBe(false);
|
||||
expect(isMobileContextActionVisible({ ...action, mobileVisible: true })).toBe(true);
|
||||
expect(isMobileContextActionVisible({ ...action, mobileVisible: true, hidden: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { WidgetContextMenuAction, WidgetMobilePresentation } from "@homarr/widgets";
|
||||
import type { WidgetMobilePresentation } from "@homarr/widgets";
|
||||
|
||||
export const resolveMobileItemPresentation = (
|
||||
item: { kind: WidgetKind; height: number },
|
||||
@@ -8,20 +8,6 @@ export const resolveMobileItemPresentation = (
|
||||
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,
|
||||
supportsDetails: mobile?.supportsDetailView ?? item.kind !== "app",
|
||||
eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"),
|
||||
unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false),
|
||||
});
|
||||
|
||||
export const shouldRenderMobileWidgetActions = ({
|
||||
supportsDetails,
|
||||
supportsRefresh,
|
||||
visibleContextActionCount,
|
||||
}: {
|
||||
supportsDetails: boolean;
|
||||
supportsRefresh: boolean;
|
||||
visibleContextActionCount: number;
|
||||
}) => supportsDetails || supportsRefresh || visibleContextActionCount > 0;
|
||||
|
||||
export const isMobileContextActionVisible = (action: WidgetContextMenuAction) =>
|
||||
action.mobileVisible === true && !action.hidden;
|
||||
@@ -1,28 +0,0 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { focusMobileBoardSection } from "./mobile-section-navigation";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("focusMobileBoardSection", () => {
|
||||
test.each([
|
||||
[false, "smooth"],
|
||||
[true, "auto"],
|
||||
] as const)("scrolls to and focuses the heading (reduced motion: %s)", (reduceMotion, behavior) => {
|
||||
const heading = document.createElement("h2");
|
||||
heading.id = "section";
|
||||
heading.tabIndex = -1;
|
||||
heading.scrollIntoView = vi.fn();
|
||||
document.body.append(heading);
|
||||
|
||||
expect(focusMobileBoardSection({ anchorId: heading.id, reduceMotion })).toBe(true);
|
||||
expect(heading.scrollIntoView).toHaveBeenCalledWith({ behavior, block: "start" });
|
||||
expect(document.activeElement).toBe(heading);
|
||||
});
|
||||
|
||||
test("does nothing when the section no longer exists", () => {
|
||||
expect(focusMobileBoardSection({ anchorId: "missing", reduceMotion: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
interface FocusMobileBoardSectionOptions {
|
||||
anchorId: string;
|
||||
reduceMotion: boolean;
|
||||
}
|
||||
|
||||
export const focusMobileBoardSection = ({ anchorId, reduceMotion }: FocusMobileBoardSectionOptions): boolean => {
|
||||
const heading = document.getElementById(anchorId);
|
||||
if (!heading) return false;
|
||||
|
||||
heading.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" });
|
||||
heading.focus({ preventScroll: true });
|
||||
return true;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useMemo } 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";
|
||||
@@ -18,9 +18,8 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/wid
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { useHasNestedDialog } from "./mobile-dialog-stack";
|
||||
import { useItemActions } from "../items/item-actions";
|
||||
import classes from "./mobile-board.module.css";
|
||||
import { isMobileContextActionVisible, shouldRenderMobileWidgetActions } from "./mobile-presentation";
|
||||
|
||||
interface MobileWidgetActionsProps {
|
||||
item: SectionItem;
|
||||
@@ -31,16 +30,18 @@ interface MobileWidgetActionsProps {
|
||||
export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => {
|
||||
const [actionsOpened, actionsDisclosure] = useDisclosure(false);
|
||||
const [detailsOpened, detailsDisclosure] = useDisclosure(false);
|
||||
const actionTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const detailsDialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const openDetailsAfterActionsRef = useRef(false);
|
||||
const hasNestedDialog = useHasNestedDialog(detailsOpened, detailsDialogRef);
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { updateItemOptions } = useItemActions();
|
||||
const definition = widgetImports[item.kind].definition;
|
||||
const queryKey = "queryKey" in definition ? (definition.queryKey as QueryKey | undefined) : undefined;
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = useMemo(
|
||||
() => ("queryKey" in definition ? (definition.queryKey as QueryKey) : [["widget", item.kind]]),
|
||||
[definition, item.kind],
|
||||
);
|
||||
const isFetching = useIsFetching({ queryKey }) > 0;
|
||||
const options = useMemo(
|
||||
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
|
||||
[item.kind, item.options, settings],
|
||||
@@ -50,32 +51,36 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
|
||||
|
||||
return definition.contextActions({
|
||||
options,
|
||||
setOptions: () => undefined,
|
||||
setOptions: (partial: Record<string, unknown>) =>
|
||||
updateItemOptions({
|
||||
itemId: item.id,
|
||||
newOptions: { ...options, ...partial },
|
||||
}),
|
||||
integrationIds: item.integrationIds,
|
||||
context: { isEditMode, boardId: board.id, itemId: item.id },
|
||||
widgetStateRef,
|
||||
} as never) as WidgetContextMenuAction[];
|
||||
}, [board.id, definition, isEditMode, item.id, item.integrationIds, options, widgetStateRef]);
|
||||
const visibleContextActions = contextActions.filter(isMobileContextActionVisible);
|
||||
}, [board.id, definition, isEditMode, item.id, item.integrationIds, options, updateItemOptions, widgetStateRef]);
|
||||
const visibleContextActions = contextActions.filter((action) => !action.hidden);
|
||||
const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
const hasActions = shouldRenderMobileWidgetActions({
|
||||
supportsDetails,
|
||||
supportsRefresh: queryKey !== undefined,
|
||||
visibleContextActionCount: visibleContextActions.length,
|
||||
});
|
||||
const hasActions = item.kind !== "app" || supportsDetails || visibleContextActions.length > 0;
|
||||
|
||||
if (!hasActions) return null;
|
||||
|
||||
const refresh = () => {
|
||||
void queryClient.refetchQueries({ queryKey, type: "all" });
|
||||
actionsDisclosure.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionIcon
|
||||
ref={actionTriggerRef}
|
||||
className={classes.widgetAction}
|
||||
variant="filled"
|
||||
color="dark"
|
||||
radius="xl"
|
||||
size={44}
|
||||
aria-label={t("board.mobile.actions", { title })}
|
||||
aria-label={t("board.mobile.actions")}
|
||||
onClick={actionsDisclosure.open}
|
||||
>
|
||||
<IconDotsVertical size={20} />
|
||||
@@ -89,21 +94,6 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
|
||||
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 && (
|
||||
@@ -113,8 +103,8 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
|
||||
size="lg"
|
||||
leftSection={<IconMaximize size={20} />}
|
||||
onClick={() => {
|
||||
openDetailsAfterActionsRef.current = true;
|
||||
actionsDisclosure.close();
|
||||
detailsDisclosure.open();
|
||||
}}
|
||||
>
|
||||
{t("board.mobile.details")}
|
||||
@@ -140,74 +130,37 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{queryKey && <MobileWidgetRefreshAction queryKey={queryKey} onComplete={actionsDisclosure.close} />}
|
||||
<Button
|
||||
variant="subtle"
|
||||
justify="flex-start"
|
||||
size="lg"
|
||||
disabled={isFetching}
|
||||
leftSection={isFetching ? <Loader size={20} /> : <IconRefresh size={20} />}
|
||||
onClick={refresh}
|
||||
>
|
||||
{t("item.menu.label.refresh")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<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,
|
||||
height: "calc(100dvh - 4rem)",
|
||||
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}
|
||||
/>
|
||||
<BoardItemContent item={item} displayMode="mobileDetail" disableContextMenu widgetStateRef={widgetStateRef} />
|
||||
</Box>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileWidgetRefreshAction = ({ queryKey, onComplete }: { queryKey: QueryKey; onComplete: () => void }) => {
|
||||
const t = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
const isFetching = useIsFetching({ queryKey }) > 0;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="subtle"
|
||||
justify="flex-start"
|
||||
size="lg"
|
||||
disabled={isFetching}
|
||||
leftSection={isFetching ? <Loader size={20} /> : <IconRefresh size={20} />}
|
||||
onClick={() => {
|
||||
void queryClient.refetchQueries({ queryKey, type: "all" });
|
||||
onComplete();
|
||||
}}
|
||||
>
|
||||
{t("item.menu.label.refresh")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,243 +0,0 @@
|
||||
import { chromium, devices, expect } from "@playwright/test";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import SuperJSON from "superjson";
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { eq } from "@homarr/db";
|
||||
import * as sqliteSchema from "@homarr/db/schema/sqlite";
|
||||
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
import type { SqliteDatabase } from "./shared/e2e-db";
|
||||
import { seedAdminUserAsync } from "./shared/seed-admin-user";
|
||||
|
||||
const adminCredentials = {
|
||||
username: "admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
};
|
||||
|
||||
const boardName = "mobile-e2e";
|
||||
|
||||
const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => {
|
||||
const boardId = createId();
|
||||
const layoutId = createId();
|
||||
const firstSectionId = createId();
|
||||
const secondSectionId = createId();
|
||||
|
||||
await db.insert(sqliteSchema.boards).values({
|
||||
id: boardId,
|
||||
name: boardName,
|
||||
creatorId: userId,
|
||||
});
|
||||
await db.insert(sqliteSchema.layouts).values({
|
||||
id: layoutId,
|
||||
name: "Desktop",
|
||||
boardId,
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
});
|
||||
await db.insert(sqliteSchema.sections).values([
|
||||
{
|
||||
id: firstSectionId,
|
||||
boardId,
|
||||
kind: "category",
|
||||
name: "First section",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
id: secondSectionId,
|
||||
boardId,
|
||||
kind: "category",
|
||||
name: "Second section",
|
||||
xOffset: 0,
|
||||
yOffset: 20,
|
||||
},
|
||||
]);
|
||||
|
||||
const itemRows = Array.from({ length: 36 }, (_, index) => {
|
||||
const id = createId();
|
||||
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({}),
|
||||
};
|
||||
});
|
||||
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,
|
||||
})),
|
||||
);
|
||||
await db
|
||||
.update(sqliteSchema.users)
|
||||
.set({
|
||||
homeBoardId: boardId,
|
||||
mobileHomeBoardId: boardId,
|
||||
completedBoardTour: true,
|
||||
})
|
||||
.where(eq(sqliteSchema.users.id, userId));
|
||||
};
|
||||
|
||||
describe("Automatic mobile board", () => {
|
||||
test("keeps phones and narrow tablets read-only, two-column, accessible, and overflow-free", async () => {
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const { userId } = await seedAdminUserAsync(db, adminCredentials);
|
||||
await seedMobileBoardAsync(db, userId);
|
||||
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: {
|
||||
AUTH_PROVIDERS: "credentials",
|
||||
},
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
const browser = await chromium.launch();
|
||||
const loginContext = await browser.newContext();
|
||||
|
||||
try {
|
||||
const loginPage = await loginContext.newPage();
|
||||
await loginPage.goto(`${baseUrl}/auth/login`);
|
||||
await loginPage.getByLabel("Username").fill(adminCredentials.username);
|
||||
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 });
|
||||
const storageState = await loginContext.storageState();
|
||||
|
||||
const mobileViewports = [
|
||||
{ name: "small phone", width: 320, height: 568, userAgent: devices["iPhone 13"].userAgent, phone: true },
|
||||
{ name: "phone", width: 390, height: 844, userAgent: devices["iPhone 13"].userAgent, phone: true },
|
||||
{ name: "large phone", width: 430, height: 932, userAgent: devices["iPhone 13"].userAgent, phone: true },
|
||||
{
|
||||
name: "phone landscape",
|
||||
width: 844,
|
||||
height: 390,
|
||||
userAgent: devices["iPhone 13"].userAgent,
|
||||
phone: true,
|
||||
},
|
||||
{
|
||||
name: "narrow tablet",
|
||||
width: 768,
|
||||
height: 1024,
|
||||
userAgent: devices["iPad (gen 7)"].userAgent,
|
||||
phone: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const viewport of mobileViewports) {
|
||||
const context = await browser.newContext({
|
||||
storageState,
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
userAgent: viewport.userAgent,
|
||||
hasTouch: true,
|
||||
isMobile: viewport.phone,
|
||||
reducedMotion: viewport.width === 390 ? "reduce" : "no-preference",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${baseUrl}/boards/${boardName}`);
|
||||
|
||||
await expect(page.locator("[data-mobile-board]"), viewport.name).toBeVisible({ timeout: 15_000 });
|
||||
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();
|
||||
|
||||
const layoutMetrics = await page.evaluate(() => {
|
||||
const grids = Array.from(document.querySelectorAll<HTMLElement>("[data-mobile-board] > div"));
|
||||
const twoColumnGrids = grids.filter((grid) => getComputedStyle(grid).display === "grid");
|
||||
const items = Array.from(document.querySelectorAll<HTMLElement>("[data-mobile-board-item]"));
|
||||
const focusable = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'[data-mobile-board] a[href], [data-mobile-board] button, [data-mobile-board] input, [data-mobile-board] select, [data-mobile-board] textarea, [data-mobile-board] [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
columns: twoColumnGrids.map(
|
||||
(grid) => getComputedStyle(grid).gridTemplateColumns.split(" ").filter(Boolean).length,
|
||||
),
|
||||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
overflowingItems: items.filter((item) => item.scrollWidth > item.clientWidth + 1).length,
|
||||
outOfBoundsControls: focusable.filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.left < -1 || rect.right > window.innerWidth + 1;
|
||||
}).length,
|
||||
layoutControls: document.querySelectorAll(
|
||||
'[data-testid*="layout"], button[aria-label*="resize" i], button[aria-label*="move" i]',
|
||||
).length,
|
||||
};
|
||||
});
|
||||
|
||||
expect(
|
||||
layoutMetrics.columns.length > 0 && layoutMetrics.columns.every((count) => count === 2),
|
||||
viewport.name,
|
||||
).toBe(true);
|
||||
expect(layoutMetrics.documentOverflow, viewport.name).toBeLessThanOrEqual(0);
|
||||
expect(layoutMetrics.overflowingItems, viewport.name).toBe(0);
|
||||
expect(layoutMetrics.outOfBoundsControls, viewport.name).toBe(0);
|
||||
expect(layoutMetrics.layoutControls, viewport.name).toBe(0);
|
||||
|
||||
if (viewport.width === 320) {
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.fontSize = "200%";
|
||||
});
|
||||
const zoomOverflow = await page.evaluate(() => ({
|
||||
document: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
items: Array.from(document.querySelectorAll<HTMLElement>("[data-mobile-board-item]")).filter(
|
||||
(item) => item.scrollWidth > item.clientWidth + 1,
|
||||
).length,
|
||||
}));
|
||||
expect(zoomOverflow.document).toBeLessThanOrEqual(0);
|
||||
expect(zoomOverflow.items).toBe(0);
|
||||
}
|
||||
|
||||
if (viewport.width === 390) {
|
||||
await page.getByRole("button", { name: "More" }).click();
|
||||
const moreDrawer = page.getByRole("dialog", { name: "More" });
|
||||
await expect(moreDrawer.getByRole("button", { name: "Second section" })).toBeVisible();
|
||||
await moreDrawer.getByRole("button", { name: "Second section" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Second section" })).toBeFocused();
|
||||
|
||||
const notebookActions = page.getByRole("button", { name: /Actions for Notebook/i });
|
||||
await notebookActions.click();
|
||||
await page.getByRole("button", { name: "Open widget details" }).click();
|
||||
const details = page.getByRole("dialog", { name: "Notebook" });
|
||||
await expect(details).toBeVisible();
|
||||
await details.getByRole("button", { name: "Close" }).click();
|
||||
await expect(notebookActions).toBeFocused();
|
||||
|
||||
const rtlOverflow = await page.evaluate(() => {
|
||||
document.documentElement.dir = "rtl";
|
||||
return document.documentElement.scrollWidth - document.documentElement.clientWidth;
|
||||
});
|
||||
expect(rtlOverflow).toBeLessThanOrEqual(0);
|
||||
}
|
||||
|
||||
await context.close();
|
||||
}
|
||||
|
||||
const desktopContext = await browser.newContext({
|
||||
storageState,
|
||||
viewport: { width: 800, height: 900 },
|
||||
});
|
||||
const desktopPage = await desktopContext.newPage();
|
||||
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 desktopContext.close();
|
||||
} finally {
|
||||
await loginContext.close();
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { settleIntegrationQueries } from "./settle-integrations";
|
||||
|
||||
const integrations = [
|
||||
{ id: "first", name: "First", kind: "mock" },
|
||||
{ id: "second", name: "Second", kind: "mock" },
|
||||
];
|
||||
|
||||
describe("settleIntegrationQueries", () => {
|
||||
it("retains successful integration results when another integration fails", async () => {
|
||||
const results = await settleIntegrationQueries(
|
||||
integrations,
|
||||
vi.fn(async (integration) => {
|
||||
if (integration.id === "first") throw new Error("offline");
|
||||
return integration.name;
|
||||
}),
|
||||
{ throwOnAllFailure: true },
|
||||
);
|
||||
|
||||
expect(results).toEqual(["Second"]);
|
||||
});
|
||||
|
||||
it("throws when every integration fails and throwOnAllFailure is enabled", async () => {
|
||||
await expect(
|
||||
settleIntegrationQueries(
|
||||
integrations,
|
||||
vi.fn(async () => {
|
||||
throw new Error("offline");
|
||||
}),
|
||||
{ throwOnAllFailure: true },
|
||||
),
|
||||
).rejects.toThrow("offline");
|
||||
});
|
||||
|
||||
it("does not treat an empty integration list as a failure", async () => {
|
||||
await expect(
|
||||
settleIntegrationQueries([], vi.fn(async () => "unused"), { throwOnAllFailure: true }),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -2,19 +2,15 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Children, isValidElement, useEffect, useRef, useState } from "react";
|
||||
import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton, useDirection } from "@mantine/core";
|
||||
import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface PaginatedListProps {
|
||||
children: ReactNode;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
|
||||
const tCommon = useScopedI18n("common");
|
||||
const { dir } = useDirection();
|
||||
const items = Children.toArray(children);
|
||||
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -30,8 +26,6 @@ export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
|
||||
const clampedPage = Math.min(page, totalPages - 1);
|
||||
const start = clampedPage * pageSize;
|
||||
const visible = items.slice(start, start + pageSize);
|
||||
const PreviousIcon = dir === "rtl" ? IconChevronRight : IconChevronLeft;
|
||||
const NextIcon = dir === "rtl" ? IconChevronLeft : IconChevronRight;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
@@ -40,24 +34,22 @@ export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
|
||||
<Group justify="center" gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size={44}
|
||||
size="sm"
|
||||
disabled={clampedPage === 0}
|
||||
aria-label={tCommon("action.previous")}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
<PreviousIcon size={14} />
|
||||
<IconChevronLeft size={14} />
|
||||
</ActionIcon>
|
||||
<Text size="xs" c="dimmed">
|
||||
{clampedPage + 1} / {totalPages}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size={44}
|
||||
size="sm"
|
||||
disabled={clampedPage >= totalPages - 1}
|
||||
aria-label={tCommon("action.next")}
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
>
|
||||
<NextIcon size={14} />
|
||||
<IconChevronRight size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { optionsBuilder } from "../options";
|
||||
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconBadgeCc,
|
||||
queryKey: [["widget", "bazarr"]],
|
||||
supportedIntegrations: ["bazarr"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary";
|
||||
import { useBeszelFilteredSystems } from "./hooks";
|
||||
|
||||
interface BeszelSystemsMobileSummaryProps {
|
||||
integrationIds: string[];
|
||||
label: string;
|
||||
statusFilter: string;
|
||||
}
|
||||
|
||||
export const BeszelSystemsMobileSummary = ({
|
||||
integrationIds,
|
||||
label,
|
||||
statusFilter,
|
||||
}: BeszelSystemsMobileSummaryProps) => {
|
||||
const t = useI18n();
|
||||
const { data: results = [], error, isPending } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const systems = useBeszelFilteredSystems(results, statusFilter);
|
||||
const failedResults = results.filter((result) => "error" in result);
|
||||
|
||||
if (isPending) return <WidgetMobileLoading />;
|
||||
if (error) throw error;
|
||||
if (results.length > 0 && failedResults.length === results.length) {
|
||||
throw new Error(String(failedResults[0]?.error ?? "Unable to connect to Beszel"));
|
||||
}
|
||||
|
||||
const up = systems.filter((system) => system.status === "up").length;
|
||||
|
||||
return (
|
||||
<WidgetMobileSummary
|
||||
value={systems.length}
|
||||
label={label}
|
||||
description={t("board.mobile.systemStatus", {
|
||||
up,
|
||||
attention: systems.length - up,
|
||||
})}
|
||||
isStale={failedResults.length > 0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,57 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { Center, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Center, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
interface WidgetMobileSummaryProps {
|
||||
value: number | string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isStale?: boolean;
|
||||
}
|
||||
|
||||
export function WidgetMobileSummary({ value, label, description, isStale = false }: WidgetMobileSummaryProps) {
|
||||
const t = useI18n();
|
||||
|
||||
export function WidgetMobileSummary({ value, label, description }: WidgetMobileSummaryProps) {
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Stack align="center" gap={2} w="100%" style={{ minWidth: 0 }}>
|
||||
<Text fz="xl" fw={700} lh={1.15} ta="center" w="100%" lineClamp={2} style={{ overflowWrap: "anywhere" }}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} lineClamp={1} ta="center" w="100%">
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap="xs" justify="center" wrap="nowrap" w="100%">
|
||||
<Text fz="xl" fw={700} lh={1}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
{description !== undefined && (
|
||||
<Text c="var(--mantine-color-text)" size="xs" lineClamp={1} ta="center" w="100%">
|
||||
<Text c="dimmed" size="xs" lineClamp={1} ta="center" w="100%">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{isStale && (
|
||||
<Group component="output" gap={4} justify="center" wrap="nowrap" w="100%">
|
||||
<IconAlertTriangle
|
||||
size={14}
|
||||
color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"
|
||||
aria-hidden
|
||||
/>
|
||||
<Text c="var(--mantine-color-text)" size="xs" lineClamp={1}>
|
||||
{t("board.mobile.dataWarning")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetMobileLoading() {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Center component="output" h="100%" aria-label={t("common.action.loading")}>
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -13,10 +13,9 @@ interface ApplicationsSectionProps {
|
||||
applications: CoolifyApplicationWithContext[];
|
||||
baseUrl: string;
|
||||
isTiny: boolean;
|
||||
isMobileDetail: boolean;
|
||||
}
|
||||
|
||||
export function ApplicationsSection({ applications, baseUrl, isTiny, isMobileDetail }: ApplicationsSectionProps) {
|
||||
export function ApplicationsSection({ applications, baseUrl, isTiny }: ApplicationsSectionProps) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const tCommon = useScopedI18n("common");
|
||||
const runningApps = applications.filter((app) => parseStatus(app.status ?? "") === "running").length;
|
||||
@@ -35,14 +34,7 @@ export function ApplicationsSection({ applications, baseUrl, isTiny, isMobileDet
|
||||
{applications.length > 0 ? (
|
||||
<Stack gap={4}>
|
||||
{applications.map((app) => (
|
||||
<ResourceRow
|
||||
key={app.uuid}
|
||||
item={app}
|
||||
baseUrl={baseUrl}
|
||||
isTiny={isTiny}
|
||||
resourceType="application"
|
||||
isMobileDetail={isMobileDetail}
|
||||
/>
|
||||
<ResourceRow key={app.uuid} item={app} baseUrl={baseUrl} isTiny={isTiny} resourceType="application" />
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
|
||||
@@ -6,8 +6,6 @@ import { optionsBuilder } from "../options";
|
||||
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconCloud,
|
||||
mobile: { supportsDetailView: true },
|
||||
queryKey: [["widget", "coolify"]],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showServers: factory.switch({
|
||||
|
||||
@@ -6,8 +6,6 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
|
||||
icon: IconApi,
|
||||
mobile: { supportsDetailView: true },
|
||||
queryKey: [["widget", "customApi"]],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
|
||||
|
||||
@@ -42,21 +42,12 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
|
||||
setMinutes(0);
|
||||
}}
|
||||
title={t("widget.dnsHoleControls.controls.setTimer")}
|
||||
closeButtonProps={{
|
||||
"aria-label": t("common.action.close"),
|
||||
size: 44,
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" align="center" justify="center">
|
||||
<Stack align="flex-end">
|
||||
<Group>
|
||||
<Text>{t("widget.dnsHoleControls.controls.hours")}</Text>
|
||||
<ActionIcon
|
||||
size={44}
|
||||
variant="default"
|
||||
aria-label={`${t("widget.dnsHoleControls.controls.hours")} −`}
|
||||
onClick={() => hoursHandlers.current?.decrement()}
|
||||
>
|
||||
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.decrement()}>
|
||||
–
|
||||
</ActionIcon>
|
||||
<NumberInput
|
||||
@@ -67,26 +58,15 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
|
||||
max={999}
|
||||
min={0}
|
||||
step={1}
|
||||
aria-label={t("widget.dnsHoleControls.controls.hours")}
|
||||
styles={{ input: { width: rem(54), textAlign: "center" } }}
|
||||
/>
|
||||
<ActionIcon
|
||||
size={44}
|
||||
variant="default"
|
||||
aria-label={`${t("widget.dnsHoleControls.controls.hours")} +`}
|
||||
onClick={() => hoursHandlers.current?.increment()}
|
||||
>
|
||||
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.increment()}>
|
||||
+
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Group>
|
||||
<Text>{t("widget.dnsHoleControls.controls.minutes")}</Text>
|
||||
<ActionIcon
|
||||
size={44}
|
||||
variant="default"
|
||||
aria-label={`${t("widget.dnsHoleControls.controls.minutes")} −`}
|
||||
onClick={() => minutesHandlers.current?.decrement()}
|
||||
>
|
||||
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.decrement()}>
|
||||
–
|
||||
</ActionIcon>
|
||||
<NumberInput
|
||||
@@ -97,15 +77,9 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
|
||||
max={59}
|
||||
min={0}
|
||||
step={1}
|
||||
aria-label={t("widget.dnsHoleControls.controls.minutes")}
|
||||
styles={{ input: { width: rem(54), textAlign: "center" } }}
|
||||
/>
|
||||
<ActionIcon
|
||||
size={44}
|
||||
variant="default"
|
||||
aria-label={`${t("widget.dnsHoleControls.controls.minutes")} +`}
|
||||
onClick={() => minutesHandlers.current?.increment()}
|
||||
>
|
||||
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.increment()}>
|
||||
+
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
@@ -117,7 +91,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<IconClockPause size={20} />}
|
||||
mih={44}
|
||||
h="2rem"
|
||||
w="12rem"
|
||||
onClick={handleSetTimer}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,6 @@ export const widgetKind = "dnsHoleControls";
|
||||
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconDeviceGamepad,
|
||||
mobile: { supportsDetailView: true },
|
||||
queryKey: [["widget", "dnsHole"]],
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
|
||||
@@ -25,13 +25,6 @@ const columnTranslationKeyMap = {
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
|
||||
icon: IconBrandDocker,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
unmountWhenOffscreen: true,
|
||||
},
|
||||
queryKey: [["docker", "getContainers"]],
|
||||
refetchInterval: 30,
|
||||
createOptions() {
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetMobileSummary } from "../common/mobile-summary";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
const Notebook = dynamic(() => import("./notebook").then((module) => module.Notebook), {
|
||||
@@ -12,23 +9,5 @@ const Notebook = dynamic(() => import("./notebook").then((module) => module.Note
|
||||
});
|
||||
|
||||
export default function NotebookWidget(props: WidgetComponentProps<"notebook">) {
|
||||
const t = useScopedI18n("widget.notebook");
|
||||
|
||||
if (props.displayMode === "mobileSummary") {
|
||||
const excerpt = props.options.content
|
||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/&(?:nbsp|#160);/gi, " ")
|
||||
.replace(/&(?:amp|#38);/gi, "&")
|
||||
.replace(/&(?:lt|#60);/gi, "<")
|
||||
.replace(/&(?:gt|#62);/gi, ">")
|
||||
.replace(/&(?:quot|#34);/gi, '"')
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
return <WidgetMobileSummary value={excerpt || "—"} label={t("name")} />;
|
||||
}
|
||||
|
||||
return <Notebook {...props} />;
|
||||
}
|
||||
@@ -6,12 +6,6 @@ import { defaultContent } from "./default-content";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("notebook", {
|
||||
icon: IconNotes,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
|
||||
@@ -6,7 +6,6 @@ import { optionsBuilder } from "../options";
|
||||
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconFileText,
|
||||
queryKey: [["widget", "paperlessNgx"]],
|
||||
refetchInterval: null,
|
||||
supportedIntegrations: ["paperlessNgx"],
|
||||
integrationsRequired: true,
|
||||
|
||||
@@ -9,8 +9,6 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation";
|
||||
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconShieldCheck,
|
||||
mobile: { supportsDetailView: true },
|
||||
queryKey: [["widget", "patchmon"]],
|
||||
supportedIntegrations: ["patchmon"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
@@ -192,42 +190,18 @@ export const { definition, componentLoader } = createWidgetDefinition("patchmon"
|
||||
useCustomThresholds: {
|
||||
shouldHide: (options) => !options.enableThresholdColors,
|
||||
},
|
||||
hostsNeedingUpdatesThresholdMode: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
hostsNeedingUpdatesWarningAt: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
hostsNeedingUpdatesCriticalAt: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
securityUpdatesThresholdMode: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
securityUpdatesWarningAt: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
securityUpdatesCriticalAt: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
hostsWithSecurityUpdatesThresholdMode: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
hostsWithSecurityUpdatesWarningAt: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
hostsWithSecurityUpdatesCriticalAt: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
upToDateHostsThresholdMode: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
upToDateHostsWarningAt: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
upToDateHostsCriticalAt: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
hostsNeedingUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
hostsNeedingUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
hostsNeedingUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
securityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
securityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
securityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
hostsWithSecurityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
hostsWithSecurityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
hostsWithSecurityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
upToDateHostsThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
upToDateHostsWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
upToDateHostsCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
totalOutdatedPackagesThresholdMode: {
|
||||
shouldHide: (options) =>
|
||||
!options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages,
|
||||
|
||||
@@ -7,13 +7,6 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
|
||||
export const { definition, componentLoader } = createWidgetDefinition("systemDisks", {
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconServer2,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
unmountWhenOffscreen: true,
|
||||
},
|
||||
queryKey: [["widget", "healthMonitoring"]],
|
||||
refetchInterval: 5,
|
||||
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
|
||||
|
||||
@@ -8,8 +8,6 @@ import { optionsBuilder } from "../options";
|
||||
export const { componentLoader, definition } = createWidgetDefinition("vpn", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconShieldLock,
|
||||
mobile: { supportsDetailView: true },
|
||||
queryKey: [["widget", "vpn"]],
|
||||
refetchInterval: null,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(() => ({}));
|
||||
|
||||
Reference in new issue
Block a user