diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index ce875ebbe..837f8b06c 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -19,6 +19,10 @@ import { BoardItemMenu } from "./item-menu"; interface BoardItemContentProps { item: SectionItem; + fallbackDimensions?: { + width: number; + height: number; + }; } const getOverflowFromKind = (kind: SectionItem["kind"]) => { @@ -27,16 +31,11 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => { return undefined; }; -const ASSUMED_VIEWPORT_WIDTH = 900; -const DEFAULT_COLUMN_COUNT = 12; - -export const BoardItemContent = ({ item }: BoardItemContentProps) => { +export const BoardItemContent = ({ item, fallbackDimensions }: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); const board = useRequiredBoard(); - - const estimatedCellSize = ASSUMED_VIEWPORT_WIDTH / DEFAULT_COLUMN_COUNT; - const fallbackWidth = item.width * estimatedCellSize - 20; - const fallbackHeight = item.height * estimatedCellSize - 20; + const fallbackWidth = fallbackDimensions?.width ?? 0; + const fallbackHeight = fallbackDimensions?.height ?? 0; return ( <> diff --git a/apps/nextjs/src/components/board/sections/category-section.tsx b/apps/nextjs/src/components/board/sections/category-section.tsx index 4c3958859..0ed6c4486 100644 --- a/apps/nextjs/src/components/board/sections/category-section.tsx +++ b/apps/nextjs/src/components/board/sections/category-section.tsx @@ -1,48 +1,21 @@ import { Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; -import { clientApi } from "@homarr/api/client"; -import { useSession } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; import type { CategorySection } from "~/app/[locale]/boards/_types"; import { CategoryMenu } from "./category/category-menu"; import { GridStack } from "./gridstack/gridstack"; import classes from "./item.module.css"; - -const localStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`; +import { useCategoryCollapse } from "./use-category-collapse"; interface Props { section: CategorySection; } export const BoardCategorySection = ({ section }: Props) => { - const { status } = useSession(); - const { mutate } = clientApi.section.changeCollapsed.useMutation(); const board = useRequiredBoard(); - - const initialCollapsed = - typeof window !== "undefined" - ? (localStorage.getItem(localStorageKey(section.id)) ?? String(section.collapsed)) === "true" - : section.collapsed; - - const [opened, { toggle }] = useDisclosure(initialCollapsed, { - onOpen() { - if (status === "authenticated") { - mutate({ sectionId: section.id, collapsed: true }); - } else if (status === "unauthenticated") { - localStorage.setItem(localStorageKey(section.id), "true"); - } - }, - onClose() { - if (status === "authenticated") { - mutate({ sectionId: section.id, collapsed: false }); - } else if (status === "unauthenticated") { - localStorage.setItem(localStorageKey(section.id), "false"); - } - }, - }); + const [opened, { toggle }] = useCategoryCollapse(section); return ( diff --git a/apps/nextjs/src/components/board/sections/static-grid-layout.spec.ts b/apps/nextjs/src/components/board/sections/static-grid-layout.spec.ts new file mode 100644 index 000000000..edcac806e --- /dev/null +++ b/apps/nextjs/src/components/board/sections/static-grid-layout.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "vitest"; + +import { BoardMockBuilder } from "../items/actions/test/mocks/board-mock"; +import { DynamicSectionMockBuilder } from "../items/actions/test/mocks/dynamic-section-mock"; +import { ItemMockBuilder } from "../items/actions/test/mocks/item-mock"; +import { LayoutMockBuilder } from "../items/actions/test/mocks/layout-mock"; +import { + getEstimatedGridWidth, + getFallbackDimensions, + getFullWidthSortedSections, + getStaticGridElements, + getStaticGridLayouts, + getStaticGridRowCount, + getStaticLayoutSelectionCss, +} from "./static-grid-layout"; + +describe("static grid layout", () => { + test("sorts layouts and creates breakpoint CSS in mobile-first order", () => { + const mobile = new LayoutMockBuilder({ id: "mobile", breakpoint: 0, columnCount: 4 }).build(); + const tablet = new LayoutMockBuilder({ id: "tablet", breakpoint: 768, columnCount: 8 }).build(); + const desktop = new LayoutMockBuilder({ id: "desktop", breakpoint: 1024, columnCount: 12 }).build(); + + const sortedLayouts = getStaticGridLayouts([desktop, mobile, tablet]); + const css = getStaticLayoutSelectionCss("board-1", sortedLayouts); + + expect(sortedLayouts.map((layout) => layout.id)).toEqual(["mobile", "tablet", "desktop"]); + expect(css).toContain('[data-static-layout="mobile"] { display: block; }'); + expect(css).toContain("@media (min-width: 768px)"); + expect(css).toContain('[data-static-layout="tablet"] { display: block; }'); + expect(css).toContain("@media (min-width: 1024px)"); + expect(css).toContain('[data-static-layout="desktop"] { display: block; }'); + }); + + test("selects and sorts layout-specific section elements", () => { + const rootSectionId = "root"; + const mobileLayoutId = "mobile"; + const desktopLayoutId = "desktop"; + const mobile = new LayoutMockBuilder({ id: mobileLayoutId, breakpoint: 0, columnCount: 4 }).build(); + const desktop = new LayoutMockBuilder({ id: desktopLayoutId, breakpoint: 1024, columnCount: 12 }).build(); + const item = new ItemMockBuilder({ id: "item" }) + .addLayout({ layoutId: mobileLayoutId, sectionId: rootSectionId, xOffset: 2, yOffset: 1, width: 1, height: 1 }) + .addLayout({ layoutId: desktopLayoutId, sectionId: rootSectionId, xOffset: 0, yOffset: 1, width: 4, height: 2 }) + .build(); + const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" }) + .addLayout({ + layoutId: mobileLayoutId, + parentSectionId: rootSectionId, + xOffset: 0, + yOffset: 0, + width: 2, + height: 2, + }) + .addLayout({ + layoutId: desktopLayoutId, + parentSectionId: rootSectionId, + xOffset: 4, + yOffset: 1, + width: 4, + height: 2, + }) + .build(); + const board = new BoardMockBuilder() + .addEmptySection({ id: rootSectionId, yOffset: 2 }) + .addEmptySection({ id: "first", yOffset: 0 }) + .addSection(dynamicSection) + .addItem(item) + .build(); + board.layouts = [desktop, mobile]; + + const mobileElements = getStaticGridElements(board, rootSectionId, mobileLayoutId); + const desktopElements = getStaticGridElements(board, rootSectionId, desktopLayoutId); + const fullWidthSections = getFullWidthSortedSections(board); + + expect(mobileElements.map((element) => element.id)).toEqual(["dynamic", "item"]); + expect(desktopElements.map((element) => ({ id: element.id, width: element.width }))).toEqual([ + { id: "item", width: 4 }, + { id: "dynamic", width: 4 }, + ]); + expect(getStaticGridRowCount(mobileElements)).toBe(2); + expect(fullWidthSections.map((section) => section.id)).toEqual(["first", rootSectionId]); + }); + + test("estimates grid and item dimensions per layout range", () => { + const mobile = new LayoutMockBuilder({ id: "mobile", breakpoint: 0, columnCount: 4 }).build(); + const tablet = new LayoutMockBuilder({ id: "tablet", breakpoint: 768, columnCount: 8 }).build(); + const desktop = new LayoutMockBuilder({ id: "desktop", breakpoint: 1024, columnCount: 12 }).build(); + + expect(getEstimatedGridWidth(mobile, tablet)).toBe(384); + expect(getEstimatedGridWidth(tablet, desktop)).toBe(896); + expect(getEstimatedGridWidth(desktop)).toBe(1280); + expect(getFallbackDimensions({ width: 2, height: 3 }, 400, 4)).toEqual({ width: 180, height: 280 }); + }); +}); diff --git a/apps/nextjs/src/components/board/sections/static-grid-layout.ts b/apps/nextjs/src/components/board/sections/static-grid-layout.ts new file mode 100644 index 000000000..db6b17d2b --- /dev/null +++ b/apps/nextjs/src/components/board/sections/static-grid-layout.ts @@ -0,0 +1,103 @@ +import type { Board, DynamicSectionItem, EmptySection, SectionItem } from "~/app/[locale]/boards/_types"; + +type BoardLayout = Board["layouts"][number]; +type StaticGridElement = SectionItem | DynamicSectionItem; + +const STATIC_GRID_ITEM_INSET = 20; +const DEFAULT_SMALL_VIEWPORT_WIDTH = 360; +const DEFAULT_LARGE_VIEWPORT_WIDTH = 1280; + +export const getStaticGridLayouts = (layouts: Board["layouts"]) => + [...layouts].sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint); + +const escapeCssString = (value: string) => value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); + +export const getStaticLayoutSelectionCss = (boardId: string, layouts: Board["layouts"]) => { + const sortedLayouts = getStaticGridLayouts(layouts); + const initialLayout = sortedLayouts.at(0); + + if (!initialLayout) return ""; + + const scope = `[data-static-board="${escapeCssString(boardId)}"]`; + const css = [ + `${scope} .static-board-layout { display: none; }`, + `${scope} .static-board-layout[data-static-layout="${escapeCssString(initialLayout.id)}"] { display: block; }`, + ]; + + for (const layout of sortedLayouts.slice(1)) { + css.push( + `@media (min-width: ${layout.breakpoint}px) { ${scope} .static-board-layout { display: none; } ${scope} .static-board-layout[data-static-layout="${escapeCssString(layout.id)}"] { display: block; } }`, + ); + } + + return css.join("\n"); +}; + +export const getFullWidthSortedSections = (board: Board) => + board.sections + .filter( + (section): section is EmptySection | Extract => + section.kind === "empty" || section.kind === "category", + ) + .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); + +export const getSectionItemsForLayout = (board: Board, sectionId: string, layoutId: string): SectionItem[] => { + return board.items + .map(({ layouts, ...item }) => { + const layout = layouts.find((candidate) => candidate.layoutId === layoutId); + if (!layout) return null; + return { ...layout, ...item, type: "item" as const }; + }) + .filter((item): item is SectionItem => item !== null && item.sectionId === sectionId); +}; + +export const getDynamicSectionsForLayout = ( + board: Board, + sectionId: string, + layoutId: string, +): DynamicSectionItem[] => { + return board.sections + .filter((section) => section.kind === "dynamic") + .map(({ layouts, ...section }) => { + const layout = layouts.find((candidate) => candidate.layoutId === layoutId); + if (!layout) return null; + return { ...layout, ...section, type: "section" as const }; + }) + .filter((entry): entry is DynamicSectionItem => entry !== null && entry.parentSectionId === sectionId); +}; + +export const getStaticGridElements = (board: Board, sectionId: string, layoutId: string): StaticGridElement[] => { + return [ + ...getSectionItemsForLayout(board, sectionId, layoutId), + ...getDynamicSectionsForLayout(board, sectionId, layoutId), + ].sort((itemA, itemB) => { + if (itemA.yOffset === itemB.yOffset) return itemA.xOffset - itemB.xOffset; + return itemA.yOffset - itemB.yOffset; + }); +}; + +export const getStaticGridRowCount = (items: StaticGridElement[], fixedRowCount?: number) => { + return fixedRowCount ?? Math.max(...items.map((item) => item.yOffset + item.height)); +}; + +export const getEstimatedGridWidth = (layout: BoardLayout, nextLayout?: BoardLayout) => { + if (nextLayout) { + const upperBound = Math.max(nextLayout.breakpoint - 1, DEFAULT_SMALL_VIEWPORT_WIDTH); + return Math.max(DEFAULT_SMALL_VIEWPORT_WIDTH, Math.round((layout.breakpoint + upperBound) / 2)); + } + + return Math.max(layout.breakpoint, DEFAULT_LARGE_VIEWPORT_WIDTH); +}; + +export const getFallbackDimensions = ( + item: Pick, + estimatedGridWidth: number, + columnCount: number, +) => { + const cellSize = estimatedGridWidth / columnCount; + + return { + width: Math.max(0, item.width * cellSize - STATIC_GRID_ITEM_INSET), + height: Math.max(0, item.height * cellSize - STATIC_GRID_ITEM_INSET), + }; +}; diff --git a/apps/nextjs/src/components/board/sections/static-grid.tsx b/apps/nextjs/src/components/board/sections/static-grid.tsx index 842a0be99..374602f58 100644 --- a/apps/nextjs/src/components/board/sections/static-grid.tsx +++ b/apps/nextjs/src/components/board/sections/static-grid.tsx @@ -1,11 +1,9 @@ "use client"; import { Badge, Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; -import { clientApi } from "@homarr/api/client"; -import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; +import { useRequiredBoard } from "@homarr/boards/context"; import type { Board, @@ -17,44 +15,62 @@ import type { import { BoardItemContent } from "../items/item-content"; import { CategoryMenu } from "./category/category-menu"; import classes from "./item.module.css"; +import { + getEstimatedGridWidth, + getFallbackDimensions, + getFullWidthSortedSections, + getStaticGridElements, + getStaticGridLayouts, + getStaticGridRowCount, + getStaticLayoutSelectionCss, +} from "./static-grid-layout"; +import { useCategoryCollapse } from "./use-category-collapse"; interface StaticBoardGridProps { board: Board; } export const StaticBoardGrid = ({ board }: StaticBoardGridProps) => { - const currentLayoutId = useCurrentLayout(); - const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId); - const columnCount = currentLayout?.columnCount ?? 12; + const boardId = `board-${board.id}`; + const layouts = getStaticGridLayouts(board.layouts); + const layoutSelectionCss = getStaticLayoutSelectionCss(boardId, layouts); + const fullWidthSortedSections = getFullWidthSortedSections(board); - const fullWidthSortedSections = board.sections - .filter( - (section): section is CategorySection | EmptySection => section.kind === "empty" || section.kind === "category", - ) - .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); + if (layouts.length === 0) return null; return ( - - {fullWidthSortedSections.map((section) => - section.kind === "empty" ? ( - - ) : ( - - ), - )} - + <> + +
+ {layouts.map((layout, index) => ( +
+ + {fullWidthSortedSections.map((section) => + section.kind === "empty" ? ( + + ) : ( + + ), + )} + +
+ ))} +
+ ); }; @@ -63,6 +79,7 @@ interface StaticSectionGridProps { board: Board; layoutId: string; columnCount: number; + estimatedGridWidth: number; isDynamic?: boolean; fixedRowCount?: number; } @@ -72,19 +89,15 @@ const StaticSectionGrid = ({ board, layoutId, columnCount, + estimatedGridWidth, isDynamic, fixedRowCount, }: StaticSectionGridProps) => { - const items = getSectionItems(board, sectionId, layoutId); - const innerSections = getDynamicSections(board, sectionId, layoutId); - const allItems = [...items, ...innerSections].sort((itemA, itemB) => { - if (itemA.yOffset === itemB.yOffset) return itemA.xOffset - itemB.xOffset; - return itemA.yOffset - itemB.yOffset; - }); + const allItems = getStaticGridElements(board, sectionId, layoutId); if (allItems.length === 0) return null; - const rowCount = fixedRowCount ?? Math.max(...allItems.map((item) => item.yOffset + item.height)); + const rowCount = getStaticGridRowCount(allItems, fixedRowCount); return (
@@ -100,7 +113,14 @@ const StaticSectionGrid = ({ }} > {allItems.map((item) => ( - + ))}
@@ -111,40 +131,59 @@ interface StaticGridItemProps { item: SectionItem | DynamicSectionItem; board: Board; layoutId: string; + estimatedGridWidth: number; + columnCount: number; } -const StaticGridItem = ({ item, board, layoutId }: StaticGridItemProps) => ( -
-
- {item.type === "item" ? ( - - ) : ( - - )} +const StaticGridItem = ({ item, board, layoutId, estimatedGridWidth, columnCount }: StaticGridItemProps) => { + const fallbackDimensions = getFallbackDimensions(item, estimatedGridWidth, columnCount); + + return ( +
+
+ {item.type === "item" ? ( + + ) : ( + + )} +
-
-); + ); +}; interface StaticEmptySectionProps { section: EmptySection; board: Board; layoutId: string; columnCount: number; + estimatedGridWidth: number; } -const StaticEmptySection = ({ section, board, layoutId, columnCount }: StaticEmptySectionProps) => { - const items = getSectionItems(board, section.id, layoutId); - const innerSections = getDynamicSections(board, section.id, layoutId); +const StaticEmptySection = ({ section, board, layoutId, columnCount, estimatedGridWidth }: StaticEmptySectionProps) => { + const items = getStaticGridElements(board, section.id, layoutId); - if (items.length === 0 && innerSections.length === 0) return null; + if (items.length === 0) return null; - return ; + return ( + + ); }; interface StaticCategorySectionProps { @@ -152,29 +191,29 @@ interface StaticCategorySectionProps { board: Board; layoutId: string; columnCount: number; + estimatedGridWidth: number; } -const StaticCategorySection = ({ section, board, layoutId, columnCount }: StaticCategorySectionProps) => { - const { mutate } = clientApi.section.changeCollapsed.useMutation(); +const StaticCategorySection = ({ + section, + board, + layoutId, + columnCount, + estimatedGridWidth, +}: StaticCategorySectionProps) => { const boardData = useRequiredBoard(); - const [opened, { toggle }] = useDisclosure(section.collapsed); - - const handleToggle = () => { - toggle(); - mutate({ sectionId: section.id, collapsed: !opened }); - }; + const [opened, { toggle }] = useCategoryCollapse(section); return ( - + {opened ? : } {section.name} @@ -182,8 +221,14 @@ const StaticCategorySection = ({ section, board, layoutId, columnCount }: Static - - + + @@ -194,9 +239,10 @@ interface StaticDynamicSectionProps { section: DynamicSectionItem; board: Board; layoutId: string; + estimatedGridWidth: number; } -const StaticDynamicSection = ({ section, board, layoutId }: StaticDynamicSectionProps) => { +const StaticDynamicSection = ({ section, board, layoutId, estimatedGridWidth }: StaticDynamicSectionProps) => { const boardData = useRequiredBoard(); const options = section.options; @@ -205,7 +251,6 @@ const StaticDynamicSection = ({ section, board, layoutId }: StaticDynamicSection className={classes.itemCard} w="100%" h="100%" - withBorder styles={{ root: { overflow: "visible", @@ -235,30 +280,10 @@ const StaticDynamicSection = ({ section, board, layoutId }: StaticDynamicSection board={board} layoutId={layoutId} columnCount={section.width} + estimatedGridWidth={estimatedGridWidth} isDynamic fixedRowCount={section.height} /> ); }; - -function getSectionItems(board: Board, sectionId: string, layoutId: string): SectionItem[] { - return board.items - .map(({ layouts, ...item }) => { - const layout = layouts.find((candidate) => candidate.layoutId === layoutId); - if (!layout) return null; - return { ...layout, ...item, type: "item" as const }; - }) - .filter((item): item is SectionItem => item !== null && item.sectionId === sectionId); -} - -function getDynamicSections(board: Board, sectionId: string, layoutId: string): DynamicSectionItem[] { - return board.sections - .filter((section) => section.kind === "dynamic") - .map(({ layouts, ...section }) => { - const layout = layouts.find((candidate) => candidate.layoutId === layoutId); - if (!layout) return null; - return { ...layout, ...section, type: "section" as const }; - }) - .filter((entry): entry is DynamicSectionItem => entry !== null && entry.parentSectionId === sectionId); -} diff --git a/apps/nextjs/src/components/board/sections/use-category-collapse.ts b/apps/nextjs/src/components/board/sections/use-category-collapse.ts new file mode 100644 index 000000000..cfba03532 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/use-category-collapse.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; + +import type { CategorySection } from "~/app/[locale]/boards/_types"; + +export const getCategoryCollapseStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`; + +const categoryCollapseChangeEventName = "homarr-section-collapse-change"; + +interface CategoryExpandedChangeEventDetail { + sectionId: string; + expanded: boolean; +} + +const getInitialExpanded = (section: CategorySection) => { + if (typeof window === "undefined") return section.collapsed; + + return (localStorage.getItem(getCategoryCollapseStorageKey(section.id)) ?? String(section.collapsed)) === "true"; +}; + +export const useCategoryCollapse = (section: CategorySection) => { + const { status } = useSession(); + const { mutate } = clientApi.section.changeCollapsed.useMutation(); + const [opened, setOpened] = useState(() => getInitialExpanded(section)); + + const persistExpanded = useCallback( + (expanded: boolean) => { + if (status === "authenticated") { + mutate({ sectionId: section.id, collapsed: expanded }); + } else if (status === "unauthenticated") { + localStorage.setItem(getCategoryCollapseStorageKey(section.id), String(expanded)); + } + }, + [mutate, section.id, status], + ); + + useEffect(() => { + const handleCollapseChange = (event: Event) => { + const { sectionId, expanded } = (event as CustomEvent).detail; + if (sectionId === section.id) { + setOpened(expanded); + } + }; + + window.addEventListener(categoryCollapseChangeEventName, handleCollapseChange); + + return () => { + window.removeEventListener(categoryCollapseChangeEventName, handleCollapseChange); + }; + }, [section.id]); + + const toggle = useCallback(() => { + setOpened((current) => { + const expanded = !current; + persistExpanded(expanded); + + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(categoryCollapseChangeEventName, { + detail: { + sectionId: section.id, + expanded, + }, + }), + ); + } + + return expanded; + }); + }, [persistExpanded, section.id]); + + return [opened, { toggle }] as const; +}; diff --git a/e2e/board-responsive.spec.ts b/e2e/board-responsive.spec.ts new file mode 100644 index 000000000..ffb14bd19 --- /dev/null +++ b/e2e/board-responsive.spec.ts @@ -0,0 +1,304 @@ +import { chromium } from "@playwright/test"; +import { createId } from "@paralleldrive/cuid2"; +import SuperJSON from "superjson"; +import { describe, expect, test } from "vitest"; + +import * as sqliteSchema from "../packages/db/schema/sqlite"; +import { hashPasswordAsync } from "../packages/auth/security"; +import { createHomarrContainer } from "./shared/create-homarr-container"; +import { createSqliteDbFileAsync } from "./shared/e2e-db"; +import type { SqliteDatabase } from "./shared/e2e-db"; + +const boardName = "responsive-board"; +const credentials = { + username: "responsive-admin", + password: "Comp(exP4sswOrd", +}; +const viewports = [ + { width: 320, height: 568 }, + { width: 375, height: 667 }, + { width: 768, height: 1024 }, + { width: 1024, height: 768 }, + { width: 1366, height: 768 }, + { width: 1920, height: 1080 }, +]; + +describe("Board responsive layout", () => { + test("renders static board layouts across common viewports and loads edit mode", async () => { + const { db, localMountPath } = await createSqliteDbFileAsync(); + const { categorySectionId } = await seedResponsiveBoardAsync(db); + const homarrContainer = await createHomarrContainer({ + mounts: { + "/appdata": localMountPath, + }, + }).start(); + const browser = await chromium.launch(); + const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; + + try { + for (const viewport of viewports) { + const context = await browser.newContext({ viewport }); + await context.addInitScript(() => { + window.__homarrLayoutShiftScore = 0; + new PerformanceObserver((entries) => { + for (const entry of entries.getEntries()) { + const layoutShift = entry as PerformanceEntry & { value: number; hadRecentInput: boolean }; + if (!layoutShift.hadRecentInput) { + window.__homarrLayoutShiftScore += layoutShift.value; + } + } + }).observe({ type: "layout-shift", buffered: true }); + }); + await context.addInitScript( + ({ sectionId }) => { + localStorage.setItem(`homarr-section-collapsed-${sectionId}`, "true"); + }, + { sectionId: categorySectionId }, + ); + const page = await context.newPage(); + + await page.goto(`${baseUrl}/boards/${boardName}`); + await page.waitForSelector(".static-grid-item-content", { state: "visible" }); + await page.waitForTimeout(500); + + const result = await page.evaluate(() => { + const visibleLayout = Array.from(document.querySelectorAll(".static-board-layout")).find( + (element) => getComputedStyle(element).display !== "none", + ); + const grids = Array.from(visibleLayout?.querySelectorAll(".static-grid") ?? []); + const hasOverlappingItems = grids.some((grid) => { + const rects = Array.from(grid.children) + .map((element) => element.getBoundingClientRect()) + .filter((rect) => rect.width > 0 && rect.height > 0); + + return rects.some((rect, index) => + rects.slice(index + 1).some((otherRect) => { + const horizontalOverlap = Math.min(rect.right, otherRect.right) - Math.max(rect.left, otherRect.left); + const verticalOverlap = Math.min(rect.bottom, otherRect.bottom) - Math.max(rect.top, otherRect.top); + return horizontalOverlap > 1 && verticalOverlap > 1; + }), + ); + }); + const visibleItems = Array.from( + visibleLayout?.querySelectorAll(".static-grid-item-content") ?? [], + ).filter((element) => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + + return { + hasVisibleLayout: Boolean(visibleLayout), + visibleItemCount: visibleItems.length, + hasHorizontalOverflow: document.documentElement.scrollWidth > window.innerWidth + 1, + hasOverlappingItems, + layoutShiftScore: window.__homarrLayoutShiftScore, + }; + }); + + expect(result.hasVisibleLayout).toBe(true); + expect(result.visibleItemCount).toBe(4); + expect(result.hasHorizontalOverflow).toBe(false); + expect(result.hasOverlappingItems).toBe(false); + expect(result.layoutShiftScore).toBeLessThanOrEqual(0.01); + + await context.close(); + } + + const context = await browser.newContext({ viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await page.goto(`${baseUrl}/auth/login`); + await page.getByLabel("Username").fill(credentials.username); + await page.getByLabel("Password").fill(credentials.password); + await page.locator("css=button[type='submit']").click(); + await page.waitForURL(baseUrl); + await page.goto(`${baseUrl}/boards/${boardName}`); + await page.waitForSelector(".static-grid-item-content", { state: "visible" }); + + await page.keyboard.press("ControlOrMeta+E"); + + await page.waitForSelector(".grid-stack", { state: "visible" }); + await expect.poll(() => page.locator(".grid-stack-item").count()).toBeGreaterThan(0); + await context.close(); + } finally { + await browser.close(); + await homarrContainer.stop(); + } + }, 120_000); +}); + +const seedResponsiveBoardAsync = async (db: SqliteDatabase) => { + const userId = createId(); + const boardId = createId(); + const mobileLayoutId = createId(); + const tabletLayoutId = createId(); + const desktopLayoutId = createId(); + const rootSectionId = createId(); + const categorySectionId = createId(); + const dynamicSectionId = createId(); + + await db.update(sqliteSchema.onboarding).set({ step: "finish" }); + await db.insert(sqliteSchema.users).values({ + id: userId, + name: credentials.username, + password: await hashPasswordAsync(credentials.password), + }); + await db.insert(sqliteSchema.boards).values({ + id: boardId, + name: boardName, + isPublic: true, + creatorId: userId, + }); + await db.insert(sqliteSchema.layouts).values([ + { id: mobileLayoutId, name: "Mobile", boardId, columnCount: 4, breakpoint: 0 }, + { id: tabletLayoutId, name: "Tablet", boardId, columnCount: 8, breakpoint: 768 }, + { id: desktopLayoutId, name: "Desktop", boardId, columnCount: 12, breakpoint: 1024 }, + ]); + await db.insert(sqliteSchema.sections).values([ + { id: rootSectionId, boardId, kind: "empty", xOffset: 0, yOffset: 0 }, + { id: categorySectionId, boardId, kind: "category", xOffset: 0, yOffset: 1, name: "Systems" }, + { + id: dynamicSectionId, + boardId, + kind: "dynamic", + options: SuperJSON.stringify({ title: "Nested", borderColor: "", customCssClasses: [] }), + }, + ]); + await db.insert(sqliteSchema.sectionLayouts).values([ + { + sectionId: dynamicSectionId, + layoutId: mobileLayoutId, + parentSectionId: categorySectionId, + xOffset: 0, + yOffset: 0, + width: 4, + height: 2, + }, + { + sectionId: dynamicSectionId, + layoutId: tabletLayoutId, + parentSectionId: categorySectionId, + xOffset: 0, + yOffset: 0, + width: 4, + height: 2, + }, + { + sectionId: dynamicSectionId, + layoutId: desktopLayoutId, + parentSectionId: categorySectionId, + xOffset: 0, + yOffset: 0, + width: 6, + height: 2, + }, + ]); + + const rootItemId = await insertClockItemAsync(db, boardId); + const categoryItemId = await insertClockItemAsync(db, boardId); + const dynamicItemId = await insertClockItemAsync(db, boardId); + await db.insert(sqliteSchema.itemLayouts).values([ + { + itemId: rootItemId, + sectionId: rootSectionId, + layoutId: mobileLayoutId, + xOffset: 0, + yOffset: 0, + width: 2, + height: 1, + }, + { + itemId: rootItemId, + sectionId: rootSectionId, + layoutId: tabletLayoutId, + xOffset: 0, + yOffset: 0, + width: 3, + height: 1, + }, + { + itemId: rootItemId, + sectionId: rootSectionId, + layoutId: desktopLayoutId, + xOffset: 0, + yOffset: 0, + width: 4, + height: 1, + }, + { + itemId: categoryItemId, + sectionId: categorySectionId, + layoutId: mobileLayoutId, + xOffset: 0, + yOffset: 2, + width: 4, + height: 1, + }, + { + itemId: categoryItemId, + sectionId: categorySectionId, + layoutId: tabletLayoutId, + xOffset: 4, + yOffset: 0, + width: 4, + height: 2, + }, + { + itemId: categoryItemId, + sectionId: categorySectionId, + layoutId: desktopLayoutId, + xOffset: 6, + yOffset: 0, + width: 6, + height: 2, + }, + { + itemId: dynamicItemId, + sectionId: dynamicSectionId, + layoutId: mobileLayoutId, + xOffset: 0, + yOffset: 0, + width: 2, + height: 1, + }, + { + itemId: dynamicItemId, + sectionId: dynamicSectionId, + layoutId: tabletLayoutId, + xOffset: 0, + yOffset: 0, + width: 2, + height: 1, + }, + { + itemId: dynamicItemId, + sectionId: dynamicSectionId, + layoutId: desktopLayoutId, + xOffset: 0, + yOffset: 0, + width: 3, + height: 1, + }, + ]); + + return { + categorySectionId, + }; +}; + +const insertClockItemAsync = async (db: SqliteDatabase, boardId: string) => { + const itemId = createId(); + await db.insert(sqliteSchema.items).values({ + id: itemId, + boardId, + kind: "clock", + options: SuperJSON.stringify({ is24HourFormat: true }), + advancedOptions: SuperJSON.stringify({ title: null, customCssClasses: [], borderColor: "" }), + }); + return itemId; +}; + +declare global { + interface Window { + __homarrLayoutShiftScore: number; + } +} diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts index 32b1f25c3..b407d0e45 100644 --- a/packages/old-import/src/widgets/options.ts +++ b/packages/old-import/src/widgets/options.ts @@ -44,6 +44,7 @@ const optionMapping: OptionMapping = { hideIcon: (oldOptions) => oldOptions.items.some((item) => item.hideIcon), hideHostname: (oldOptions) => oldOptions.items.some((item) => item.hideHostname), openNewTab: (oldOptions) => oldOptions.items.some((item) => item.openNewTab), + withBorder: () => undefined, }, calendar: { releaseType: (oldOptions) => [oldOptions.radarrReleaseType],