fix: keep mobile board widgets unchanged

This commit is contained in:
ajnart committed 2026-07-29 20:21:59 +02:00
1 parent e2732dce2c
commit b5ffefe707
43 files changed
+1669 -181

No files matched your search

@@ -60,9 +60,15 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
return layouts.map((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
const layoutItemSize = boardLayout
? {
...itemSize,
width: Math.min(itemSize.width, boardLayout.columnCount),
}
: itemSize;
const emptyPosition = boardLayout
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, layoutItemSize)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) {
@@ -70,8 +76,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
}
return {
width: itemSize.width,
height: itemSize.height,
width: layoutItemSize.width,
height: layoutItemSize.height,
...emptyPosition,
sectionId: currentSection.id,
layoutId,
@@ -7,7 +7,7 @@ export const getFirstEmptyPosition = (
size: { width: number; height: number } = { width: 1, height: 1 },
) => {
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
const isOccupied = elements.some(
(element) =>
element.yOffset < yOffset + size.height &&
@@ -1,9 +1,8 @@
import { getCurrentLayout } from "@homarr/boards/context";
import type { Board } from "~/app/[locale]/boards/_types";
export interface MoveAndResizeItemInput {
itemId: string;
layoutId: string;
xOffset: number;
yOffset: number;
width: number;
@@ -11,10 +10,8 @@ export interface MoveAndResizeItemInput {
}
export const moveAndResizeItemCallback =
({ itemId, ...layoutInput }: MoveAndResizeItemInput) =>
({ itemId, layoutId, ...layoutInput }: MoveAndResizeItemInput) =>
(previous: Board): Board => {
const currentLayout = getCurrentLayout(previous);
return {
...previous,
items: previous.items.map((item) =>
@@ -23,7 +20,7 @@ export const moveAndResizeItemCallback =
: {
...item,
layouts: item.layouts.map((layout) =>
layout.layoutId !== currentLayout
layout.layoutId !== layoutId
? layout
: {
...layout,
@@ -1,9 +1,8 @@
import { getCurrentLayout } from "@homarr/boards/context";
import type { Board } from "~/app/[locale]/boards/_types";
export interface MoveItemToSectionInput {
itemId: string;
layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
@@ -12,10 +11,8 @@ export interface MoveItemToSectionInput {
}
export const moveItemToSectionCallback =
({ itemId, ...layoutInput }: MoveItemToSectionInput) =>
({ itemId, layoutId, ...layoutInput }: MoveItemToSectionInput) =>
(board: Board): Board => {
const currentLayout = getCurrentLayout(board);
return {
...board,
items: board.items.map((item) =>
@@ -24,7 +21,7 @@ export const moveItemToSectionCallback =
: {
...item,
layouts: item.layouts.map((layout) =>
layout.layoutId !== currentLayout
layout.layoutId !== layoutId
? layout
: {
...layout,
@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
@@ -10,6 +10,10 @@ import { ItemMockBuilder } from "./mocks/item-mock";
import { LayoutMockBuilder } from "./mocks/layout-mock";
describe("item actions create-item", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("should add it to first section", () => {
// Arrange
const itemKind = "clock";
@@ -108,4 +112,24 @@ describe("item actions create-item", () => {
{ height: 1, width: 1 },
);
});
test("clamps wide widgets to each retained layout", () => {
const desktopLayout = new LayoutMockBuilder({ id: "desktop", columnCount: 12 }).build();
const mobileLayout = new LayoutMockBuilder({ id: "mobile", columnCount: 1 }).build();
const board = new BoardMockBuilder()
.addLayout(desktopLayout)
.addLayout(mobileLayout)
.addEmptySection({ id: "root", yOffset: 0 })
.build();
const result = createItemCallback({ kind: "mediaMissing" })(board);
const item = result.items.at(0);
expect(item?.layouts).toEqual(
expect.arrayContaining([
expect.objectContaining({ layoutId: desktopLayout.id, width: 4 }),
expect.objectContaining({ layoutId: mobileLayout.id, width: 1 }),
]),
);
});
});
@@ -106,6 +106,10 @@ describe("get first empty position", () => {
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
});
test("does not place an item wider than the layout", () => {
expect(getFirstEmptyPosition([], 1, undefined, { width: 2, height: 1 })).toBeUndefined();
});
});
const createElementsFromLayout = (layout: string[][]) => {
@@ -1,6 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import { describe, expect, test } from "vitest";
import { moveAndResizeItemCallback } from "../move-and-resize-item";
import { BoardMockBuilder } from "./mocks/board-mock";
@@ -11,8 +9,6 @@ describe("moveItemToSectionCallback should move item in section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
xOffset: 20,
yOffset: 30,
@@ -25,7 +21,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
// Act
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(3);
@@ -43,8 +39,6 @@ describe("moveItemToSectionCallback should move item in section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
xOffset: 20,
yOffset: 30,
@@ -56,7 +50,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
// Act
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(2);
@@ -1,6 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import { describe, expect, test } from "vitest";
import { moveItemToSectionCallback } from "../move-item-to-section";
import { BoardMockBuilder } from "./mocks/board-mock";
@@ -11,8 +9,6 @@ describe("moveItemToSectionCallback should move item to section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
sectionId: "3",
xOffset: 20,
@@ -26,7 +22,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
// Act
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(3);
@@ -44,8 +40,6 @@ describe("moveItemToSectionCallback should move item to section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
sectionId: "3",
xOffset: 20,
@@ -58,7 +52,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
// Act
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(2);
@@ -16,5 +16,4 @@
.item > :global(.grid-stack-item-content) {
width: 100%;
height: 100%;
overflow-y: auto;
}
@@ -93,4 +93,25 @@ describe("createMobileBoardItems", () => {
expect(result.map((item) => item.id)).toEqual(["second", "first"]);
expect(result[1]).toMatchObject({ width: 1, height: 1 });
});
test("keeps useful desktop footprints within the two-by-three mobile limit", () => {
const board = new BoardMockBuilder().build();
const desktopLayoutId = board.layouts.at(0)?.id;
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
board.sections.push(rootSection);
board.items.push(
new ItemMockBuilder({ id: "two-by-two" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 2, height: 2 })
.build(),
new ItemMockBuilder({ id: "one-by-one" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, xOffset: 2, width: 1, height: 1 })
.build(),
);
expect(createMobileBoardItems(board, desktopLayoutId)).toMatchObject([
{ id: "two-by-two", width: 2, height: 2 },
{ id: "one-by-one", width: 1, height: 1 },
]);
});
});
@@ -1,7 +1,7 @@
import { useCallback } from "react";
import { fetchApi } from "@homarr/api/client";
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
@@ -18,6 +18,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
const t = useI18n();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const createCategoryAtYOffset = useCallback(
(position: number) => {
@@ -104,7 +105,6 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const settings = useSettings();
const openAllInNewTabs = useCallback(async () => {
const currentLayoutId = getCurrentLayout(board);
const appIds = filterByItemKind(
board.items.filter(
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
@@ -130,7 +130,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
});
break;
}
}, [category, board, t, openConfirmModal, settings]);
}, [category, board, currentLayoutId, t, openConfirmModal, settings]);
return {
addCategoryAbove,
@@ -1,7 +1,7 @@
import { Badge, Box, Card } from "@mantine/core";
import combineClasses from "clsx";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useRequiredBoard } from "@homarr/boards/context";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
@@ -14,7 +14,6 @@ interface Props {
export const BoardDynamicSection = ({ section }: Props) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const options = section.options;
return (
@@ -52,8 +51,7 @@ export const BoardDynamicSection = ({ section }: Props) => {
{options.title}
</Badge>
)}
{/* Use unique key by layout to reinitialize gridstack */}
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
<GridStack section={section} className="min-row" />
</Card>
<BoardDynamicSectionMenu section={section} />
</Box>
@@ -4,6 +4,8 @@ import type { BoxProps } from "@mantine/core";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
import { useCurrentLayout } from "@homarr/boards/context";
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
import { SectionContent } from "../content";
import { SectionProvider } from "../section-context";
@@ -15,6 +17,12 @@ interface Props extends BoxProps {
}
export const GridStack = ({ section, ...props }: Props) => {
const currentLayoutId = useCurrentLayout();
return <GridStackForLayout key={currentLayoutId} section={section} {...props} />;
};
const GridStackForLayout = ({ section, ...props }: Props) => {
const { items, innerSections } = useSectionItems(section.id);
const itemIds = [...items, ...innerSections].map((item) => item.id);
@@ -103,6 +103,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
// Updates the react-query state
moveAndResizeItem({
itemId: id,
layoutId: currentLayoutId,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -120,6 +121,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
if (type === "section") {
moveAndResizeInnerSection({
innerSectionId: id,
layoutId: currentLayoutId,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -136,7 +138,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
console.error(`Unknown grid-stack-item type to move. type='${type}' id='${id}'`);
},
[moveAndResizeItem, moveAndResizeInnerSection],
[currentLayoutId, moveAndResizeItem, moveAndResizeInnerSection],
);
const onAdd = useCallback(
(addedNode: GridStackNode) => {
@@ -149,6 +151,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
// Updates the react-query state
moveItemToSection({
itemId: id,
layoutId: currentLayoutId,
sectionId: section.id,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
@@ -167,6 +170,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
if (type === "section") {
moveInnerSectionToSection({
innerSectionId: id,
layoutId: currentLayoutId,
sectionId: section.id,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
@@ -184,7 +188,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
console.error(`Unknown grid-stack-item type to add. type='${type}' id='${id}'`);
},
[moveItemToSection, moveInnerSectionToSection, section.id],
[currentLayoutId, moveItemToSection, moveInnerSectionToSection, section.id],
);
// initialize the gridstack
@@ -1,10 +1,10 @@
import { useCallback } from "react";
import { getCurrentLayout } from "@homarr/boards/context";
import { useUpdateBoard } from "@homarr/boards/updater";
interface MoveAndResizeInnerSection {
innerSectionId: string;
layoutId: string;
xOffset: number;
yOffset: number;
width: number;
@@ -12,6 +12,7 @@ interface MoveAndResizeInnerSection {
}
interface MoveInnerSectionToSection {
innerSectionId: string;
layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
@@ -23,7 +24,7 @@ export const useSectionActions = () => {
const { updateBoard } = useUpdateBoard();
const moveAndResizeInnerSection = useCallback(
({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
({ innerSectionId, layoutId, ...positionProps }: MoveAndResizeInnerSection) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((section) => {
@@ -31,12 +32,10 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "dynamic") return section;
const currentLayout = getCurrentLayout(previous);
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.layoutId !== currentLayout) return layout;
if (layout.layoutId !== layoutId) return layout;
return {
...layout,
...positionProps,
@@ -50,7 +49,7 @@ export const useSectionActions = () => {
);
const moveInnerSectionToSection = useCallback(
({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
({ innerSectionId, layoutId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
updateBoard((previous) => {
return {
...previous,
@@ -59,12 +58,10 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "dynamic") return section;
const currentLayout = getCurrentLayout(previous);
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.layoutId !== currentLayout) return layout;
if (layout.layoutId !== layoutId) return layout;
return {
...layout,
...positionProps,
@@ -4,6 +4,8 @@ import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import { useMediaQuery } from "@mantine/hooks";
import { useSettings } from "@homarr/settings";
export const mobileBoardMediaQuery = "(max-width: 48em)";
const InitialMobileBoardContext = createContext<boolean | undefined>(undefined);
@@ -17,5 +19,8 @@ export const MobileBoardViewportProvider = ({
export const useIsMobileBoard = () => {
const initialIsMobile = useContext(InitialMobileBoardContext);
return useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true });
const { enableAutomaticMobileLayout } = useSettings();
const isMobileViewport = useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true });
return enableAutomaticMobileLayout && isMobileViewport;
};