feat: rebuild board grid experience

This commit is contained in:
ajnart committed 2026-08-13 19:52:09 +02:00
1 parent 5dc4dbeaef
commit 0110a2ef63
122 files changed
+9582 -4063

No files matched your search

@@ -1,10 +1,12 @@
import { getBoardLayouts } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import type { Modify } from "@homarr/common/types";
import { getRootSectionLane } from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { widgetDefaultSizes } from "@homarr/definitions";
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount } from "~/components/board/layout";
import { getFirstEmptyPosition } from "./empty-position";
import { getSectionElements } from "./section-elements";
@@ -19,17 +21,21 @@ export const createItemCallback =
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
(previous: Board): Board => {
const firstSection = previous.sections
.filter((section): section is EmptySection => section.kind === "empty")
.filter(
(section): section is EmptySection =>
section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
)
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) return previous;
const itemLayouts = createItemLayouts(previous, firstSection, kind);
const widget = {
id: id ?? createId(),
kind,
options,
layouts: createItemLayouts(previous, firstSection, kind),
layouts: itemLayouts,
integrationIds,
advancedOptions: {
title: null,
@@ -60,9 +66,14 @@ 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 columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width;
const size = {
width: Math.min(itemSize.width, columnCount),
height: itemSize.height,
};
const emptyPosition = boardLayout
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
? getFirstEmptyPosition(elements, columnCount, undefined, size)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) {
@@ -70,8 +81,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
}
return {
width: itemSize.width,
height: itemSize.height,
width: size.width,
height: size.height,
...emptyPosition,
sectionId: currentSection.id,
layoutId,
@@ -1,6 +1,8 @@
import { createId } from "@homarr/common";
import { getRootSectionLane } from "@homarr/definitions";
import type { Board, EmptySection, ItemLayout, Section } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount } from "~/components/board/layout";
import { getFirstEmptyPosition } from "./empty-position";
import { getSectionElements } from "./section-elements";
@@ -15,16 +17,17 @@ export const duplicateItemCallback =
if (!itemToDuplicate) return previous;
const clonedItem = structuredClone(itemToDuplicate);
const clonedLayouts = clonedItem.layouts.map((layout) => ({
...layout,
...getNextPosition(previous, layout),
}));
return {
...previous,
items: previous.items.concat({
...clonedItem,
id: createId(),
layouts: clonedItem.layouts.map((layout) => ({
...layout,
...getNextPosition(previous, layout),
})),
layouts: clonedLayouts,
}),
};
};
@@ -42,8 +45,10 @@ const getNextPosition = (board: Board, layout: ItemLayout): { xOffset: number; y
}
const firstSection = board.sections
.filter((section): section is EmptySection => section.kind === "empty")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.filter(
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
)
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) {
@@ -67,18 +72,24 @@ const getEmptySectionPosition = (
layout: ItemLayout,
section: Section,
): { xOffset: number; yOffset: number } | undefined => {
const boardLayout = board.layouts.find((boardLayout) => boardLayout.id === layout.layoutId);
const boardLayout = board.layouts.find((candidate) => candidate.id === layout.layoutId);
if (!boardLayout) return;
const sectionElements = getSectionElements(board, { sectionId: layout.sectionId, layoutId: layout.layoutId });
const sectionElements = getSectionElements(board, { sectionId: section.id, layoutId: layout.layoutId });
if (section.kind !== "dynamic") {
return getFirstEmptyPosition(sectionElements, boardLayout.columnCount, undefined, {
const columnCount =
section.kind === "empty"
? getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset))
: boardLayout.columnCount;
const size = {
width: layout.width,
height: layout.height,
});
};
return getFirstEmptyPosition(sectionElements, columnCount, undefined, size);
}
const sectionLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layout.layoutId);
const sectionLayout = section.layouts.find((candidate) => candidate.layoutId === layout.layoutId);
if (!sectionLayout) return;
return getFirstEmptyPosition(sectionElements, sectionLayout.width, sectionLayout.height, {
@@ -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,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";
@@ -52,7 +56,7 @@ describe("item actions create-item", () => {
],
}),
);
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, 9999, { height: 1, width: 1 });
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
});
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
// Arrange
@@ -94,8 +98,10 @@ describe("item actions create-item", () => {
// Assert
expect(result.items.length).toBe(3);
const item = result.items.find((item) => item.id !== itemInFirstSection.id && item.id !== otherItem.id);
expect(item).toEqual(
const createdItem = result.items.find(
(candidate) => candidate.id !== itemInFirstSection.id && candidate.id !== otherItem.id,
);
expect(createdItem).toEqual(
expect.objectContaining({
kind: itemKind,
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
@@ -104,8 +110,35 @@ describe("item actions create-item", () => {
expect(spy).toHaveBeenCalledWith(
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
layout.columnCount,
9999,
undefined,
{ height: 1, width: 1 },
);
});
test("clamps wide defaults and places below a full automatic canvas", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
const layout = board.layouts[0];
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
layout.columnCount = 1;
board.items.push(
new ItemMockBuilder({ id: "existing" })
.addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 })
.build(),
);
const result = createItemCallback({ id: "created", kind: "mediaMissing" })(board);
const created = result.items.find((item) => item.id === "created");
expect(created?.layouts).toEqual([
expect.objectContaining({
layoutId: layout.id,
sectionId: "canvas",
width: 1,
height: 3,
xOffset: 0,
yOffset: 1,
}),
]);
});
});
@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { duplicateItemCallback } from "../duplicate-item";
import * as emptyPositionModule from "../empty-position";
@@ -7,6 +7,10 @@ import { ItemMockBuilder } from "./mocks/item-mock";
import { LayoutMockBuilder } from "./mocks/layout-mock";
describe("item actions duplicate-item", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("should copy it in the same section", () => {
// Arrange
const itemKind = "minecraftServerStatus";
@@ -61,4 +65,28 @@ describe("item actions duplicate-item", () => {
}),
);
});
test("uses the next automatic canvas row when it is the first available space", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
const layout = board.layouts[0];
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
layout.columnCount = 1;
const currentItem = new ItemMockBuilder({ id: "source" })
.addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 })
.build();
board.items.push(currentItem);
const result = duplicateItemCallback({ itemId: currentItem.id })(board);
const duplicated = result.items.find((item) => item.id !== currentItem.id);
expect(duplicated?.layouts[0]).toEqual(
expect.objectContaining({
layoutId: layout.id,
sectionId: "canvas",
xOffset: 0,
yOffset: 1,
}),
);
});
});
@@ -0,0 +1,123 @@
import { describe, expect, test } from "vitest";
import { getMoveTargets } from "../../item-move-modal";
import { BoardMockBuilder } from "./mocks/board-mock";
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
import { EmptySectionMockBuilder } from "./mocks/empty-section-mock";
import { ItemMockBuilder } from "./mocks/item-mock";
describe("item move targets", () => {
test("offers the canvas, enabled gutters, and nested sections", () => {
const board = new BoardMockBuilder().build();
const layout = board.layouts[0];
if (!layout) throw new Error("Expected the board mock to contain a layout");
layout.id = "layout";
layout.columnCount = 6;
layout.leftGutterColumnCount = 1;
layout.rightGutterColumnCount = 1;
board.sections.push(
new EmptySectionMockBuilder({ id: "canvas", xOffset: 0 }).build(),
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
new EmptySectionMockBuilder({ id: "right-gutter", xOffset: 1 }).build(),
new DynamicSectionMockBuilder({
id: "dynamic",
options: {
title: "",
customCssClasses: [],
borderColor: "",
showLabel: true,
collapsible: false,
showOpenAll: false,
},
})
.addLayout({
layoutId: layout.id,
parentSectionId: "canvas",
width: 2,
height: 2,
})
.build(),
);
const targets = getMoveTargets(
board,
layout.id,
{
id: "weather",
type: "item",
xOffset: 0,
yOffset: 0,
width: 1,
height: 1,
},
{
canvas: "Dashboard canvas",
dynamicSection: "New section",
leftRail: "Left dashboard rail",
rightRail: "Right dashboard rail",
numbered: (name, index) => `${name}, destination ${index}`,
located: (name, location, index) => `${name}, ${location}, destination ${index}`,
},
);
expect(targets.map((target) => target.label)).toEqual([
"Dashboard canvas, destination 1",
"Left dashboard rail, destination 2",
"Right dashboard rail, destination 3",
"New section, destination 4",
]);
expect(new Set(targets.map((target) => target.label))).toHaveLength(targets.length);
});
test("does not offer canvases that are narrower than a nested section's contents", () => {
const board = new BoardMockBuilder().build();
const layout = board.layouts[0];
if (!layout) throw new Error("Expected the board mock to contain a layout");
layout.id = "layout";
layout.columnCount = 5;
layout.leftGutterColumnCount = 2;
const section = new DynamicSectionMockBuilder({ id: "source" })
.addLayout({
layoutId: layout.id,
parentSectionId: "canvas",
width: 4,
height: 2,
})
.build();
board.sections.push(
new EmptySectionMockBuilder({ id: "canvas" }).build(),
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
section,
);
board.items.push(
new ItemMockBuilder({ id: "nested" })
.addLayout({
layoutId: layout.id,
sectionId: section.id,
xOffset: 2,
width: 2,
})
.build(),
);
const sourceLayout = section.layouts[0];
if (!sourceLayout) throw new Error("Expected the source section to contain a layout");
const targets = getMoveTargets(
board,
layout.id,
{ ...sourceLayout, id: section.id, type: "section" },
{
canvas: "Canvas",
dynamicSection: "Section",
leftRail: "Left",
rightRail: "Right",
numbered: (name, index) => `${name} ${index}`,
located: (name, location, index) => `${name} ${location} ${index}`,
},
);
expect(targets).toEqual([]);
});
});
@@ -45,6 +45,8 @@ export class BoardMockBuilder {
id: createId(),
name: "Base",
columnCount: 12,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
},
],
@@ -1,23 +0,0 @@
import { createId } from "@homarr/common";
import type { CategorySection } from "~/app/[locale]/boards/_types";
export class CategorySectionMockBuilder {
private readonly section: CategorySection;
constructor(section?: Partial<CategorySection>) {
this.section = {
id: createId(),
kind: "category",
xOffset: 0,
yOffset: 0,
name: "Category",
collapsed: false,
...section,
} satisfies CategorySection;
}
public build(): CategorySection {
return this.section;
}
}
@@ -1,21 +1,23 @@
import { createId } from "@homarr/common";
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
import type { DynamicSection } from "~/app/[locale]/boards/_types";
type DynamicSectionMock = Omit<Partial<DynamicSection>, "options"> & {
options?: Partial<DynamicSection["options"]>;
};
export class DynamicSectionMockBuilder {
private readonly section: DynamicSection;
constructor(section?: Partial<DynamicSection>) {
constructor(section?: DynamicSectionMock) {
this.section = {
id: createId(),
kind: "dynamic",
options: {
title: "",
borderColor: "",
customCssClasses: [],
},
layouts: [],
...section,
collapsed: section?.collapsed ?? false,
options: dynamicSectionOptionsSchema.parse(section?.options),
} satisfies DynamicSection;
}
@@ -10,6 +10,8 @@ export class LayoutMockBuilder {
id: createId(),
name: "Base",
columnCount: 12,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
...layout,
} satisfies Board["layouts"][0];
@@ -1,5 +1,6 @@
import type { MutableRefObject } from "react";
import { useRef } from "react";
import dynamic from "next/dynamic";
import { Badge, Card } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
@@ -17,9 +18,12 @@ import type { SectionItem } from "~/app/[locale]/boards/_types";
import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu";
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
ssr: false,
});
interface BoardItemContentProps {
item: SectionItem;
}
@@ -40,6 +44,9 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
<Card
ref={ref}
w="100%"
h="100%"
data-grid-item-content
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
@@ -117,7 +124,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
}}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
{isEditMode && <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />}
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
</>
)}
@@ -130,7 +137,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
}
/>
<BoardItemMenu offset={4} item={newItem} />
{isEditMode && <BoardItemMenu offset={4} item={newItem} />}
<Comp
options={options as never}
integrationIds={item.integrationIds}
@@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
@@ -13,11 +12,10 @@ import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
import { useOpenItemMoveModal } from "./item-move-modal";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface BoardItemMenuProps {
offset: number;
item: SectionItem;
definition: WidgetDefinition;
resetErrorBoundary?: () => void;
@@ -31,25 +29,21 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
return <BoardItemMenuInner {...props} />;
};
const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
const { data: session } = useSession();
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useScopedI18n("item");
const t = useI18n();
const { openModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
const openMoveModal = useOpenItemMoveModal();
const { openConfirmModal } = useConfirmModal();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const hasSupportedIntegrations =
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
enabled: isMenuOpen && hasSupportedIntegrations,
});
const { gridstack } = useSectionContext().refs;
const { integrations: integrationData = [], section } = useSectionContext();
const settings = useSettings();
const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
// Reset error boundary on next render if item has been edited
useEffect(() => {
@@ -84,7 +78,7 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
});
refResetErrorBoundaryOnNextRender.current = true;
},
integrationData: (integrationData ?? []).filter(
integrationData: integrationData.filter(
(integration) =>
"supportedIntegrations" in definition &&
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
@@ -95,8 +89,8 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
{
title(t) {
return `${t("item.edit.title")} - ${t(`widget.${item.kind}.name`)}`;
title(translate) {
return `${translate("item.edit.title")} - ${translate(`widget.${item.kind}.name`)}`;
},
},
);
@@ -117,14 +111,16 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
<Menu.Target>
<ActionIcon
variant="default"
radius={"xl"}
size={24}
radius="sm"
pos="absolute"
top={offset}
right={offset}
top={12}
right={8}
style={{ zIndex: 10 }}
aria-label={tItem("menu.label.settings")}
data-board-widget-settings
aria-label={tItem("menu.label.settingsFor", { name: label })}
>
<IconDotsVertical size={"1rem"} />
<IconDotsVertical size="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
@@ -134,24 +130,25 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
onClick={openEditModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
disabled={hasSupportedIntegrations && isPending}
>
{tItem("action.edit")}
</Menu.Item>
<Menu.Item
leftSection={<IconLayoutKanban size={16} />}
onFocus={preloadItemMoveModal}
onPointerEnter={preloadItemMoveModal}
onClick={() => {
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
openMoveModal({
entry: item,
sourceSectionId: section.id,
});
}}
>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
{tItem("action.duplicate")}
</Menu.Item>
{canDuplicate && (
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
{tItem("action.duplicate")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
@@ -1,94 +1,220 @@
import { useCallback, useRef } from "react";
import { Grid, NumberInput, Stack } from "@mantine/core";
import { useCallback, useMemo } from "react";
import { Grid, NumberInput, Select, Stack } from "@mantine/core";
import { z } from "zod/v4";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { getRootSectionLane } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import type { GridStack } from "@homarr/gridstack";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { getLayoutRowCount } from "../layout";
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
export interface ItemMoveModalProps {
gridStack: GridStack;
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
columnCount: number;
export type MovableEntry = Pick<
SectionItem | ContainerSectionItem,
"id" | "type" | "width" | "height" | "xOffset" | "yOffset"
>;
export interface OpenItemMoveModalInput {
entry: MovableEntry;
sourceSectionId: string;
}
export const ItemMoveModal = createModal<ItemMoveModalProps>(({ actions, innerProps }) => {
const t = useI18n();
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
interface InnerProps extends OpenItemMoveModalInput {
board: Board;
currentLayoutId: string;
commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void;
}
interface MoveTarget {
id: string;
label: string;
columnCount: number;
maxRowCount: number | null;
}
interface MoveTargetLabels {
canvas: string;
container: string;
leftRail: string;
rightRail: string;
numbered: (name: string, index: number) => string;
located: (name: string, location: string, index: number) => string;
}
type MoveTargetCandidate = Omit<MoveTarget, "label"> & {
name: string;
location?: string;
};
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tLandmark = useScopedI18n("board.landmark");
const tContainer = useScopedI18n("section.container");
const tMoveResize = useScopedI18n("item.moveResize");
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
const minimumSize = useMemo(
() => getEntryMinimumSize(board, currentLayoutId, entry),
[board, currentLayoutId, entry],
);
const targets = useMemo(
() =>
getMoveTargets(board, currentLayoutId, entry, {
canvas: tLandmark("canvas"),
container: tContainer("action.create"),
leftRail: tLandmark("leftRail"),
rightRail: tLandmark("rightRail"),
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
located: (name, location, index) =>
tMoveResize("target.located", {
name,
location,
index: String(index),
}),
}),
[board, currentLayoutId, entry, tContainer, tLandmark, tMoveResize],
);
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
if (!initialTarget) {
throw new Error("No valid dashboard section is available for this item");
}
const form = useZodForm(
z.object({
xOffset: z
.number()
.min(0)
.max(innerProps.columnCount - 1),
yOffset: z.number().min(0),
width: z.number().min(1).max(maxWidthRef.current),
height: z.number().min(1),
sectionId: z.string().min(1),
xOffset: z.number().int().min(0).max(32767),
yOffset: z.number().int().min(0).max(32767),
width: z.number().int().min(1).max(32767),
height: z.number().int().min(1).max(32767),
}),
{
initialValues: {
xOffset: innerProps.item.xOffset,
yOffset: innerProps.item.yOffset,
width: innerProps.item.width,
height: innerProps.item.height,
},
onValuesChange(values, previous) {
if (values.xOffset !== previous.xOffset) {
maxWidthRef.current = innerProps.columnCount - values.xOffset;
}
sectionId: initialTarget.id,
xOffset: entry.xOffset,
yOffset: entry.yOffset,
width: entry.width,
height: entry.height,
},
},
);
const selectedTarget = targetById.get(form.values.sectionId) ?? initialTarget;
const handleSubmit = useCallback(
(values: Pick<Item["layouts"][number], "height" | "width" | "xOffset" | "yOffset">) => {
const gridItem = innerProps.gridStack
.getGridItems()
.find((item) => item.getAttribute("data-id") === innerProps.item.id);
if (!gridItem) return;
innerProps.gridStack.update(gridItem, {
h: values.height,
w: values.width,
x: values.xOffset,
(values: { sectionId: string; xOffset: number; yOffset: number; width: number; height: number }) => {
const target = targetById.get(values.sectionId);
if (!target) return;
const width = Math.max(minimumSize.width, Math.min(values.width, target.columnCount));
const height = Math.max(minimumSize.height, values.height);
const nextPlacement: SectionGridPlacement = {
id: entry.id,
type: entry.type,
x: Math.min(values.xOffset, target.columnCount - width),
y: values.yOffset,
});
w: width,
h: height,
};
const targetPlacements = getSectionGridPlacements(board, currentLayoutId, target.id).filter(
(placement) => placement.id !== entry.id,
);
const resolvedTarget = resolvePinnedGridCollisions(
[...targetPlacements, nextPlacement],
nextPlacement,
target.columnCount,
);
if (target.maxRowCount !== null && getLayoutRowCount(resolvedTarget) > target.maxRowCount) {
form.setFieldError("height", tMoveResize("keyboard.boundary"));
return;
}
const snapshots =
sourceSectionId === target.id
? [{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget }]
: [
{
layoutId: currentLayoutId,
sectionId: sourceSectionId,
placements: getSectionGridPlacements(board, currentLayoutId, sourceSectionId).filter(
(placement) => placement.id !== entry.id,
),
},
{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget },
];
commitSectionGrids(snapshots);
actions.closeModal();
focusMovedEntry(entry.id, target.id);
},
[actions, innerProps.gridStack, innerProps.item.id],
[
actions,
board,
commitSectionGrids,
currentLayoutId,
entry,
form,
minimumSize,
sourceSectionId,
targetById,
tMoveResize,
],
);
return (
<form onSubmit={form.onSubmit(handleSubmit, console.error)}>
<Stack>
<Select
data={targets.map((target) => ({ value: target.id, label: target.label }))}
label={tMoveResize("field.section.label")}
data-autofocus
allowDeselect={false}
searchable
{...form.getInputProps("sectionId")}
/>
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<NumberInput
label={t("item.moveResize.field.xOffset.label")}
data-autofocus
label={tMoveResize("field.xOffset.label")}
min={0}
max={innerProps.columnCount - 1}
max={selectedTarget.columnCount - 1}
{...form.getInputProps("xOffset")}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<NumberInput label={t("item.moveResize.field.yOffset.label")} min={0} {...form.getInputProps("yOffset")} />
<NumberInput
label={tMoveResize("field.yOffset.label")}
min={0}
max={selectedTarget.maxRowCount === null ? undefined : selectedTarget.maxRowCount - 1}
{...form.getInputProps("yOffset")}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<NumberInput
label={t("item.moveResize.field.width.label")}
min={1}
max={innerProps.columnCount - form.values.xOffset}
label={tMoveResize("field.width.label")}
min={minimumSize.width}
max={selectedTarget.columnCount - Math.min(form.values.xOffset, selectedTarget.columnCount - 1)}
{...form.getInputProps("width")}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<NumberInput label={t("item.moveResize.field.height.label")} min={1} {...form.getInputProps("height")} />
<NumberInput
label={tMoveResize("field.height.label")}
min={minimumSize.height}
max={
selectedTarget.maxRowCount === null
? undefined
: selectedTarget.maxRowCount - Math.min(form.values.yOffset, selectedTarget.maxRowCount - 1)
}
{...form.getInputProps("height")}
/>
</Grid.Col>
</Grid>
<ModalFormFooter onCancel={actions.closeModal} />
@@ -101,3 +227,137 @@ export const ItemMoveModal = createModal<ItemMoveModalProps>(({ actions, innerPr
},
size: modalSizeForm,
});
export const useOpenItemMoveModal = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { commitSectionGrids } = useGridLayoutActions();
const { openModal } = useModalAction(ItemMoveModal);
return useCallback(
(input: OpenItemMoveModalInput) => {
openModal({
...input,
board,
currentLayoutId,
commitSectionGrids,
});
},
[board, commitSectionGrids, currentLayoutId, openModal],
);
};
export const getMoveTargets = (
board: Board,
layoutId: string,
entry: MovableEntry,
labels: MoveTargetLabels,
): MoveTarget[] => {
const minimum = getEntryMinimumSize(board, layoutId, entry);
const candidates = board.sections.flatMap((section): MoveTargetCandidate[] => {
if (section.kind === "empty") {
const columnCount = getSectionColumnCount(board, layoutId, section.id);
const lane = getRootSectionLane(section.xOffset);
if (columnCount === 0) return [];
return minimum.width <= columnCount
? [
{
id: section.id,
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
columnCount,
maxRowCount: getSectionMaxRowCount(board, layoutId, section.id),
},
]
: [];
}
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) return [];
if (
(entry.type === "section" && isSameOrDescendant(board, layoutId, section.id, entry.id)) ||
minimum.width > layout.width ||
minimum.height > layout.height
) {
return [];
}
return [
{
id: section.id,
name: section.options.title || labels.container,
columnCount: layout.width,
maxRowCount: layout.height,
},
];
});
return candidates.map(({ name, location, ...target }, index) => ({
...target,
label: location ? labels.located(name, location, index + 1) : labels.numbered(name, index + 1),
}));
};
const getSectionColumnCount = (board: Board, layoutId: string, sectionId: string) => {
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
if (!layout) throw new Error(`Layout "${layoutId}" was not found`);
const section = board.sections.find((candidate) => candidate.id === sectionId);
if (!section) throw new Error(`Section "${sectionId}" was not found`);
const columnCount = getSectionGridColumnCount(board, layoutId, sectionId);
if (columnCount === null) throw new Error(`Section "${sectionId}" has no current layout`);
return columnCount;
};
const getSectionMaxRowCount = (board: Board, layoutId: string, sectionId: string) => {
const section = board.sections.find((candidate) => candidate.id === sectionId);
if (!section) return null;
if (section.kind === "container") {
return section.layouts.find((candidate) => candidate.layoutId === layoutId)?.height ?? null;
}
return null;
};
const getEntryMinimumSize = (board: Board, layoutId: string, entry: MovableEntry) => {
if (entry.type === "item") return { width: 1, height: 1 };
const children = getSectionGridPlacements(board, layoutId, entry.id);
return {
width: Math.max(1, ...children.map((child) => child.x + child.w)),
height: Math.max(1, ...children.map((child) => child.y + child.h)),
};
};
const isSameOrDescendant = (board: Board, layoutId: string, candidateId: string, ancestorId: string) => {
let current: string | undefined = candidateId;
const visited = new Set<string>();
while (current && !visited.has(current)) {
if (current === ancestorId) return true;
visited.add(current);
const section = board.sections.find((candidate) => candidate.id === current && candidate.kind === "container");
if (!section || section.kind !== "container") return false;
current = section.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId;
}
return false;
};
const focusMovedEntry = (entryId: string, sectionId: string) => {
let attempts = 0;
const tryFocus = () => {
const section = Array.from(document.querySelectorAll<HTMLElement>("[data-grid-section-id]")).find(
(candidate) => candidate.dataset.gridSectionId === sectionId,
);
const entry = Array.from(section?.querySelectorAll<HTMLElement>(":scope > [data-grid-item-id]") ?? []).find(
(candidate) => candidate.dataset.gridItemId === entryId,
);
const editorEntry = entry?.querySelector<HTMLElement>("[data-editor-grid-entry]");
if (editorEntry) {
editorEntry.focus();
return;
}
attempts += 1;
if (attempts < 10) requestAnimationFrame(tryFocus);
};
requestAnimationFrame(tryFocus);
};
@@ -1,53 +1,68 @@
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
import { IconApi } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createId, objectEntries } from "@homarr/common";
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
import { createId } from "@homarr/common";
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
import { useItemActions } from "./item-actions";
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
import classes from "./item-select-modal.module.css";
export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, innerProps }) => {
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
interface ItemSelectModalContentProps {
actions: { closeModal: () => void };
integrationData: RouterOutputs["integration"]["all"] | undefined;
customWidgetDefs: RouterOutputs["customWidget"]["available"] | undefined;
ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>;
isAdmin: boolean;
}
const ItemSelectModalContent = ({
actions,
integrationData,
customWidgetDefs,
ensureIntegrationDataAsync,
isAdmin,
}: ItemSelectModalContentProps) => {
const [search, setSearch] = useState("");
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
const selectionLock = useRef(false);
const t = useI18n();
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery(
{ boardId: innerProps.boardId },
{ enabled: isAdmin },
);
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
const settings = useSettings();
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
const items = useMemo(
() =>
objectEntries(widgetImports)
.filter(([kind]) => kind !== "customApi")
.map(([kind, value]) => ({
kind,
supportedIntegrations:
"supportedIntegrations" in value.definition
? value.definition.supportedIntegrations.filter((integration) => integration !== "mock")
: [],
icon: value.definition.icon,
name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`),
}))
widgetKinds
.filter((kind) => kind !== "customApi")
.map((kind) => {
return {
kind,
supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter(
(integration) => integration !== "mock",
),
icon: widgetCatalogIcons[kind],
name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`),
};
})
.sort((itemA, itemB) => {
if (itemA.kind === "app") return -1;
if (itemB.kind === "app") return 1;
@@ -72,77 +87,108 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
[customWidgetDefs, search],
);
const handleAddCustomWidget = (definition: NonNullable<typeof customWidgetDefs>[number]) => {
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
const options = {
...defaultOptions,
definitionId: definition.id,
configuration: definition.defaultOptions,
configurationVersion: definition.updatedAt.getTime(),
};
actions.closeModal();
openEditModal(
{
kind: "customApi",
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options,
integrationIds: [],
},
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
updateItemOptions({ itemId, newOptions: configuredOptions });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
},
integrationData: [],
integrationSupport: false,
settings,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${definition.name}`,
},
);
const notifyDefinitionLoadError = (error: unknown) => {
showErrorNotification({
title: t("common.error"),
message: error instanceof Error ? error.message : String(error),
});
};
const handleAdd = (kind: WidgetKind) => {
const definition = widgetImports[kind].definition;
const hasIntegrationSupport = "supportedIntegrations" in definition;
const matchingIntegrations = hasIntegrationSupport
? (integrationData ?? []).filter((integration) =>
(definition.supportedIntegrations as string[]).includes(integration.kind),
)
: [];
const integrationIds = matchingIntegrations.map((i) => i.id);
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
createItem({ id: itemId, kind, integrationIds });
actions.closeModal();
openEditModal(
{
kind,
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options: defaultOptions,
integrationIds,
const handleAddCustomWidget = async (customWidgetDefinition: NonNullable<typeof customWidgetDefs>[number]) => {
if (!tryLockSelection(selectionLock)) return;
setLoadingSelection(`custom:${customWidgetDefinition.id}`);
preloadWidgetEditModal();
try {
const definition = await loadWidgetDefinition("customApi");
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
const options = {
...defaultOptions,
definitionId: customWidgetDefinition.id,
configuration: customWidgetDefinition.defaultOptions,
configurationVersion: customWidgetDefinition.updatedAt.getTime(),
};
actions.closeModal();
openEditModal(
{
kind: "customApi",
definition,
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options,
integrationIds: [],
},
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
updateItemOptions({ itemId, newOptions: configuredOptions });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
},
integrationData: [],
integrationSupport: false,
settings,
},
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
updateItemOptions({ itemId, newOptions: options });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
{
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
},
integrationData: matchingIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
},
);
);
} catch (error) {
notifyDefinitionLoadError(error);
} finally {
unlockSelection(selectionLock);
setLoadingSelection(null);
}
};
const handleAdd = async (kind: WidgetKind) => {
if (!tryLockSelection(selectionLock)) return;
setLoadingSelection(kind);
preloadWidgetEditModal();
try {
const definition = await loadWidgetDefinition(kind);
const hasIntegrationSupport = "supportedIntegrations" in definition;
const matchingIntegrations = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport,
supportedIntegrations: definition.supportedIntegrations ?? [],
currentData: integrationData,
ensureDataAsync: ensureIntegrationDataAsync,
});
const integrationIds = matchingIntegrations.map((i) => i.id);
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
createItem({ id: itemId, kind, integrationIds });
actions.closeModal();
openEditModal(
{
kind,
definition,
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options: defaultOptions,
integrationIds,
},
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
updateItemOptions({ itemId, newOptions: options });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
},
integrationData: matchingIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
},
);
} catch (error) {
notifyDefinitionLoadError(error);
} finally {
unlockSelection(selectionLock);
setLoadingSelection(null);
}
};
return (
@@ -151,9 +197,9 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
onSearchChange={setSearch}
placeholder={`${t("item.create.search")}...`}
onSearchKeyDown={(event) => {
if (event.key === "Enter" && filteredItems.length === 1) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
handleAdd(filteredItems[0]!.kind);
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
const [item] = filteredItems;
if (item) void handleAdd(item.kind);
}
}}
>
@@ -161,7 +207,13 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
<WidgetItem
key={item.kind}
item={item}
onSelect={() => handleAdd(item.kind)}
onSelect={() => void handleAdd(item.kind)}
onIntent={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}}
disabled={loadingSelection !== null}
loading={loadingSelection === item.kind}
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
/>
))}
@@ -177,12 +229,12 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
{filteredCustomWidgets.map((def) => (
<Card
key={def.id}
className={classes.card}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
style={{ overflow: "hidden" }}
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
@@ -200,18 +252,24 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
</Text>
</Stack>
<Box
className={classes.action}
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={() => handleAddCustomWidget(def)} variant="light" size="xs" fullWidth>
<Button
onClick={() => void handleAddCustomWidget(def)}
variant="light"
size="xs"
fullWidth
disabled={loadingSelection !== null}
loading={loadingSelection === `custom:${def.id}`}
>
{t("item.create.addToBoard")}
</Button>
</Box>
@@ -231,7 +289,38 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
)}
</SelectGridLayout>
);
}).withOptions({
};
const ItemSelectModalFrame = ({
actions,
innerProps,
}: {
actions: { closeModal: () => void };
innerProps: { boardId: string };
}) => {
const utils = clientApi.useUtils();
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery(
{ boardId: innerProps.boardId },
{ enabled: isAdmin },
);
return (
<ItemSelectModalContent
actions={actions}
integrationData={integrationData}
customWidgetDefs={customWidgetDefs}
ensureIntegrationDataAsync={() => utils.integration.all.ensureData()}
isAdmin={isAdmin}
/>
);
};
export const ItemSelectModal = createModal<{ boardId: string }>((props) => (
<ItemSelectModalFrame {...props} />
)).withOptions({
defaultTitle: (t) => t("item.create.title"),
size: modalSizeSelect,
});
@@ -239,6 +328,9 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
const WidgetItem = ({
item,
onSelect,
onIntent,
disabled,
loading,
hasMatchingIntegration,
}: {
item: {
@@ -249,23 +341,27 @@ const WidgetItem = ({
icon: TablerIcon;
};
onSelect: () => void;
onIntent: () => void;
disabled: boolean;
loading: boolean;
hasMatchingIntegration: boolean;
}) => {
const t = useI18n();
return (
<Card
className={classes.card}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{
overflow: "hidden",
"--_hover-opacity": "0",
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: hasMatchingIntegration ? 2 : undefined,
}}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
onFocus={onIntent}
onPointerEnter={onIntent}
aria-busy={loading || undefined}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
@@ -282,18 +378,17 @@ const WidgetItem = ({
<SupportedIntegrations integrations={item.supportedIntegrations} />
</Stack>
<Box
className={classes.action}
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
<Button onClick={onSelect} variant="light" size="xs" fullWidth disabled={disabled} loading={loading}>
{t(`item.create.addToBoard`)}
</Button>
</Box>
@@ -3,15 +3,7 @@
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
IconCircleCheck,
IconCopy,
IconLayoutKanban,
IconRefresh,
IconSettings,
IconTrash,
} from "@tabler/icons-react";
import { IconAlertTriangle, IconCircleCheck, IconRefresh, IconSettings } from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
@@ -20,7 +12,7 @@ import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useTimeAgo } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -30,9 +22,7 @@ import { getWidgetQueryKeys } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface WidgetContextMenuProps {
@@ -44,9 +34,10 @@ interface WidgetContextMenuProps {
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const settings = useSettings();
if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>;
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
return (
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
@@ -94,25 +85,20 @@ type WidgetContextMenuDropdownProps = Omit<WidgetContextMenuProps, "children"> &
};
const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const tItem = useScopedI18n("item");
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n();
const { data: session } = useSession();
const canConfigureWidget = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const hasSupportedIntegrations =
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
const { openModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
const { openConfirmModal } = useConfirmModal();
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
enabled: hasSupportedIntegrations,
});
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
@@ -188,7 +174,7 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
);
}
},
integrationData: (integrationData ?? []).filter(
integrationData: integrationData.filter(
(integration) =>
"supportedIntegrations" in definition &&
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
@@ -266,35 +252,8 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
</>
)}
{isEditMode && (
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
closeMenuOnClick
leftSection={<IconLayoutKanban size={16} />}
onFocus={preloadItemMoveModal}
onPointerEnter={preloadItemMoveModal}
onClick={() => {
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
>
{tItem("action.moveResize")}
</Menu.Item>
{canConfigureWidget && (
<Menu.Item
closeMenuOnClick
leftSection={<IconCopy size={16} />}
onClick={() => duplicateItem({ itemId: item.id })}
>
{tItem("action.duplicate")}
</Menu.Item>
)}
</>
)}
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleRefetch}
@@ -316,31 +275,11 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
onClick={openEditModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
disabled={hasSupportedIntegrations && isPending}
disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)}
>
{tMenu("settings")}
</Menu.Item>
</>
{isEditMode && (
<>
<Menu.Divider />
<Menu.Item
closeMenuOnClick
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => {
openConfirmModal({
title: tItem("remove.title"),
children: tItem("remove.message"),
onConfirm: () => removeItem({ itemId: item.id }),
});
}}
>
{tItem("action.remove")}
</Menu.Item>
</>
)}
</>
);
};
@@ -0,0 +1,27 @@
# Fixed dashboard geometry
The board uses fixed logical units:
- one `1 × 1` item is exactly `200 × 200` logical pixels;
- the gap is always `24` logical pixels, giving a `224` pixel track pitch;
- multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`);
- the canvas is transformed once with one uniform scale derived from its available width.
Items keep the same logical dimensions at every viewport. The viewport reserves
the painted size while the inner canvas remains at its logical dimensions, so a
widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay
stable.
The read-only renderer should:
1. convert persisted `xOffset/yOffset/width/height` values to `x/y/w/h`;
2. apply collapsed container heights and call `getCollapsedDisplayLayout`;
3. render DOM children in `sortGridPlacementsForReadingOrder` order;
4. position children with `getLogicalItemStyle`;
5. wrap them with `getScaledCanvasStyles` and the read-only semantic helpers.
Edit mode can use any interaction library, but its final coordinates should be
normalized through this core. `placeGridItem` is the deterministic
keyboard/numeric fallback: the active item is pinned while collisions move out
of the way and all other items compact vertically. Pointer coordinates from a
scaled editor can be converted with `toLogicalCanvasPoint`.
@@ -0,0 +1,94 @@
import type { BoardLane } from "@homarr/definitions";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import type { Board, EmptySection } from "~/app/[locale]/boards/_types";
import { getLogicalTrackSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry";
import { getCollapsedDisplayLayout } from "./reflow";
import type { GridPlacement } from "./types";
export { getBoardLaneColumnCount };
export const getRootSectionForLane = (board: Board, lane: BoardLane): EmptySection | undefined => {
const roots = board.sections.filter(
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === lane,
);
if (roots.length > 1) {
throw new Error(`Board "${board.id}" has multiple ${lane} canvas roots`);
}
return roots[0];
};
export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) => {
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
if (!layout) return 0;
const rowCounts = (["left", "main", "right"] as const).flatMap((lane) => {
const columnCount = getBoardLaneColumnCount(layout, lane);
const root = getRootSectionForLane(board, lane);
if (columnCount === 0 || !root) return [];
const placements: GridPlacement[] = [
...board.items.flatMap((item) => {
const itemLayout = item.layouts.find(
(candidate) => candidate.layoutId === layoutId && candidate.sectionId === root.id,
);
return itemLayout
? [
normalizeGridPlacement(
{
id: item.id,
type: "item" as const,
x: itemLayout.xOffset,
y: itemLayout.yOffset,
w: itemLayout.width,
h: itemLayout.height,
},
columnCount,
),
]
: [];
}),
...board.sections.flatMap((section) => {
if (section.kind !== "dynamic") return [];
const sectionLayout = section.layouts.find(
(candidate) => candidate.layoutId === layoutId && candidate.parentSectionId === root.id,
);
return sectionLayout
? [
normalizeGridPlacement(
{
id: section.id,
type: "section" as const,
x: sectionLayout.xOffset,
y: sectionLayout.yOffset,
w: sectionLayout.width,
h: sectionLayout.height,
},
columnCount,
),
]
: [];
}),
];
const collapsedSectionIds = new Set(
board.sections
.filter(
(section) =>
section.kind === "dynamic" &&
section.collapsed &&
section.options.collapsible &&
placements.some((placement) => placement.id === section.id),
)
.map((section) => section.id),
);
const displayPlacements =
collapsedSectionIds.size > 0
? getCollapsedDisplayLayout(placements, { columnCount, collapsedItemIds: collapsedSectionIds })
: placements;
return [getLayoutRowCount(displayPlacements)];
});
return getLogicalTrackSize(Math.max(0, ...rowCounts));
};
@@ -0,0 +1,10 @@
/**
* A grid unit is a fixed logical size. The canvas is scaled as one surface;
* individual cards never change their internal layout at different viewports.
*/
export const LOGICAL_GRID_CELL_SIZE = 200;
export const LOGICAL_GRID_GAP = 24;
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
export const MIN_ACCESSIBLE_CANVAS_SCALE = 0.75;
export const COLLAPSED_SECTION_ROW_COUNT = 1;
@@ -0,0 +1,54 @@
import type { CSSProperties } from "react";
import { LOGICAL_GRID_CELL_SIZE, LOGICAL_GRID_GAP, LOGICAL_GRID_PITCH } from "./constants";
import type { GridCoordinates, GridPlacement } from "./types";
export const getLogicalTrackSize = (trackCount: number) => {
const normalizedTrackCount = toNonNegativeInteger(trackCount);
if (normalizedTrackCount === 0) return 0;
return normalizedTrackCount * LOGICAL_GRID_CELL_SIZE + (normalizedTrackCount - 1) * LOGICAL_GRID_GAP;
};
export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSProperties => ({
position: "absolute",
left: x * LOGICAL_GRID_PITCH,
top: y * LOGICAL_GRID_PITCH,
width: getLogicalTrackSize(w),
height: getLogicalTrackSize(h),
});
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
placements.reduce((rowCount, placement) => Math.max(rowCount, placement.y + placement.h), 0);
export const normalizeGridPlacement = <TPlacement extends GridPlacement>(
placement: TPlacement,
columnCount: number,
): TPlacement => {
const normalizedColumnCount = toPositiveInteger(columnCount, "columnCount");
const width = Math.min(toPositiveInteger(placement.w, "placement.w"), normalizedColumnCount);
const x = Math.min(Math.max(0, toNonNegativeInteger(placement.x)), normalizedColumnCount - width);
return {
...placement,
x,
y: toNonNegativeInteger(placement.y),
w: width,
h: toPositiveInteger(placement.h, "placement.h"),
};
};
const toNonNegativeInteger = (value: number) => Math.max(0, Math.trunc(toFiniteNumber(value, "value")));
const toPositiveInteger = (value: number, name: string) => {
const integer = Math.trunc(toFiniteNumber(value, name));
if (integer < 1) throw new RangeError(`${name} must be at least 1`);
return integer;
};
const toFiniteNumber = (value: number, name: string) => {
if (!Number.isFinite(value)) throw new TypeError(`${name} must be finite`);
return value;
};
@@ -0,0 +1,6 @@
export * from "./constants";
export * from "./board-lanes";
export * from "./geometry";
export * from "./reflow";
export * from "./semantics";
export * from "./types";
@@ -0,0 +1,140 @@
import { COLLAPSED_SECTION_ROW_COUNT } from "./constants";
import { normalizeGridPlacement } from "./geometry";
import type { GridPlacement } from "./types";
interface ReflowOptions {
columnCount: number;
/**
* A pinned item keeps its requested x/y while every other item compacts
* around it. Use this for the item currently being moved or resized.
*/
pinnedItemId?: string;
}
interface CollapsedLayoutOptions {
columnCount: number;
collapsedItemIds: ReadonlySet<string>;
collapsedRowCount?: number;
}
export const doGridPlacementsOverlap = (first: GridPlacement, second: GridPlacement) =>
first.x < second.x + second.w &&
first.x + first.w > second.x &&
first.y < second.y + second.h &&
first.y + first.h > second.y;
/**
* Resolves overlaps and vertically compacts items. Ordering is based on the
* persisted position and stable id, so the result does not depend on React or
* database array order.
*/
export const reflowVerticalLayout = <TPlacement extends GridPlacement>(
placements: readonly TPlacement[],
{ columnCount, pinnedItemId }: ReflowOptions,
): TPlacement[] => {
assertUniqueIds(placements);
const normalized = placements.map((placement) => normalizeGridPlacement(placement, columnCount));
const pinned = pinnedItemId ? normalized.find((placement) => placement.id === pinnedItemId) : undefined;
if (pinnedItemId && !pinned) {
throw new Error(`Pinned grid item "${pinnedItemId}" does not exist`);
}
const placed: TPlacement[] = pinned ? [pinned] : [];
const candidates = normalized.filter((placement) => placement.id !== pinnedItemId).toSorted(comparePlacements);
for (const candidate of candidates) {
placed.push({
...candidate,
y: findFirstAvailableRow(candidate, placed),
});
}
const byId = new Map(placed.map((placement) => [placement.id, placement]));
return normalized.map((placement) => getRequiredPlacement(byId, placement.id));
};
/**
* Produces the view-only layout after inline containers are collapsed. Only
* display geometry changes; callers retain the persisted expanded height.
*/
export const getCollapsedDisplayLayout = <TPlacement extends GridPlacement>(
placements: readonly TPlacement[],
{ columnCount, collapsedItemIds, collapsedRowCount = COLLAPSED_SECTION_ROW_COUNT }: CollapsedLayoutOptions,
): TPlacement[] => {
if (collapsedRowCount < 1 || !Number.isInteger(collapsedRowCount)) {
throw new RangeError("collapsedRowCount must be a positive integer");
}
return reflowVerticalLayout(
placements.map((placement) =>
collapsedItemIds.has(placement.id)
? {
...placement,
h: collapsedRowCount,
}
: placement,
),
{ columnCount },
);
};
/**
* Applies the requested placement and pins it while neighbors compact into
* free space or move down. This is the deterministic fallback used by
* keyboard/numeric editing and can also validate a drag library result.
*/
export const placeGridItem = <TPlacement extends GridPlacement>(
placements: readonly TPlacement[],
nextPlacement: TPlacement,
columnCount: number,
) => {
if (!placements.some((placement) => placement.id === nextPlacement.id)) {
throw new Error(`Grid item "${nextPlacement.id}" does not exist`);
}
return reflowVerticalLayout(
placements.map((placement) => (placement.id === nextPlacement.id ? nextPlacement : placement)),
{
columnCount,
pinnedItemId: nextPlacement.id,
},
);
};
export const sortGridPlacementsForReadingOrder = <TPlacement extends GridPlacement>(
placements: readonly TPlacement[],
) => placements.toSorted(comparePlacements);
const findFirstAvailableRow = (candidate: GridPlacement, placed: readonly GridPlacement[]) => {
let y = 0;
for (;;) {
const atRow = { ...candidate, y };
const collisions = placed.filter((placement) => doGridPlacementsOverlap(atRow, placement));
if (collisions.length === 0) return y;
y = Math.max(...collisions.map((placement) => placement.y + placement.h));
}
};
const comparePlacements = (first: GridPlacement, second: GridPlacement) =>
first.y - second.y || first.x - second.x || (first.id === second.id ? 0 : first.id < second.id ? -1 : 1);
const assertUniqueIds = (placements: readonly GridPlacement[]) => {
const ids = new Set<string>();
for (const placement of placements) {
if (ids.has(placement.id)) throw new Error(`Duplicate grid item id "${placement.id}"`);
ids.add(placement.id);
}
};
const getRequiredPlacement = <TPlacement extends GridPlacement>(
placements: ReadonlyMap<string, TPlacement>,
id: string,
) => {
const placement = placements.get(id);
if (!placement) throw new Error(`Grid item "${id}" was lost during layout`);
return placement;
};
@@ -0,0 +1,51 @@
.viewport {
width: 100%;
max-width: 100%;
min-width: 0;
overflow: visible;
overscroll-behavior-inline: contain;
}
.viewport[data-board-hydrated="false"] {
overflow: clip;
}
.sizer {
position: relative;
}
.canvas {
position: absolute;
inset: 0 auto auto 0;
transform-origin: top left;
will-change: transform;
/*
* Keep dashboard UI at its normal readable density while the fixed canvas
* is fitted below 100%. Above 100%, the canvas still behaves like browser
* zoom and enlarges everything together.
*/
--mantine-scale: var(--board-canvas-ui-scale, 1);
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1));
--mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
}
@media (prefers-reduced-motion: reduce) {
.canvas {
will-change: auto;
}
}
@@ -0,0 +1,92 @@
"use client";
import type { PropsWithChildren } from "react";
import { useEffect, useMemo, useState } from "react";
import { Box } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { MIN_ACCESSIBLE_CANVAS_SCALE } from "./constants";
import classes from "./scaled-board-canvas.module.css";
export { MIN_ACCESSIBLE_CANVAS_SCALE };
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
return Math.max(MIN_ACCESSIBLE_CANVAS_SCALE, availableWidth / logicalWidth);
};
export const calculateBoardUiScale = (canvasScale: number) => {
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
return canvasScale < 1 ? 1 / canvasScale : 1;
};
interface ScaledBoardCanvasProps {
logicalWidth: number;
initialLogicalHeight: number;
initialAvailableWidth: number;
label: string;
}
/**
* Scales the complete board as one surface while keeping widget layout in
* fixed logical pixels. The scale floor deliberately allows horizontal
* overflow so browser zoom still magnifies content instead of being cancelled
* by responsive downscaling.
*/
export const ScaledBoardCanvas = ({
logicalWidth,
initialLogicalHeight,
initialAvailableWidth,
label,
children,
}: PropsWithChildren<ScaledBoardCanvasProps>) => {
const { ref: viewportRef, width: availableWidth } = useElementSize<HTMLDivElement>();
const { ref: canvasRef, height: logicalHeight } = useElementSize<HTMLDivElement>();
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
setIsHydrated(true);
}, []);
const resolvedAvailableWidth = availableWidth > 0 ? availableWidth : initialAvailableWidth;
const resolvedLogicalHeight = logicalHeight > 0 ? logicalHeight : initialLogicalHeight;
const scale = useMemo(
() => calculateBoardCanvasScale(resolvedAvailableWidth, logicalWidth),
[logicalWidth, resolvedAvailableWidth],
);
const uiScale = calculateBoardUiScale(scale);
const visualWidth = logicalWidth * scale;
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
return (
<Box
component="section"
ref={viewportRef}
className={classes.viewport}
data-testid="board-canvas"
data-board-hydrated={isHydrated ? "true" : "false"}
data-canvas-scale={scale}
data-canvas-overflow={hasHorizontalOverflow ? "true" : "false"}
data-canvas-initial-height={initialLogicalHeight}
data-canvas-initial-width={initialAvailableWidth}
aria-label={label}
>
<Box className={classes.sizer} w={visualWidth} h={resolvedLogicalHeight * scale}>
<Box
ref={canvasRef}
className={classes.canvas}
w={logicalWidth}
style={{
"--board-canvas-ui-scale": uiScale,
transform: `scale(${scale})`,
}}
>
{children}
</Box>
</Box>
</Box>
);
};
@@ -0,0 +1,56 @@
import type { GridPlacement } from "./types";
interface ReadonlyCanvasAttributesInput {
label: string;
}
interface EditableCanvasAttributesInput extends ReadonlyCanvasAttributesInput {
columnCount: number;
rowCount: number;
}
interface EditableGridCellAttributesInput {
label: string;
placement: GridPlacement;
}
/**
* View mode is a labelled region, not an ARIA grid. It keeps the native
* semantics and focus behavior of links, buttons and widget controls inside.
*/
export const getReadonlyCanvasAttributes = ({ label }: ReadonlyCanvasAttributesInput) =>
({
role: "region",
"aria-label": label,
"data-grid-readonly": "true",
}) as const;
export const getReadonlyGridItemAttributes = (placement: GridPlacement) =>
({
"data-grid-id": placement.id,
"data-grid-x": placement.x,
"data-grid-y": placement.y,
"data-grid-w": placement.w,
"data-grid-h": placement.h,
}) as const;
/**
* Edit mode remains a labelled region. Dashboard placement is not an ARIA
* data grid: arrow keys edit geometry rather than navigate rows and columns.
* Each tile is one focusable direct-manipulation surface, while controls inside
* it keep their native semantics and tab order.
*/
export const getEditableCanvasAttributes = ({ label }: EditableCanvasAttributesInput) =>
({
role: "region",
"aria-label": label,
"data-grid-editable": "true",
}) as const;
export const getEditableGridCellAttributes = ({ label, placement }: EditableGridCellAttributesInput) =>
({
role: "group",
"aria-label": label,
tabIndex: 0,
...getReadonlyGridItemAttributes(placement),
}) as const;
@@ -0,0 +1,112 @@
import { describe, expect, test } from "vitest";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock";
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, getRootSectionForLane } from "../index";
describe("board lanes", () => {
test("gutters consume columns from the main canvas", () => {
const layout = {
id: "layout",
name: "Base",
columnCount: 12,
leftGutterColumnCount: 2,
rightGutterColumnCount: 3,
breakpoint: 0,
} satisfies Board["layouts"][number];
expect(getBoardLaneColumnCount(layout, "left")).toBe(2);
expect(getBoardLaneColumnCount(layout, "main")).toBe(7);
expect(getBoardLaneColumnCount(layout, "right")).toBe(3);
});
test("clamps invalid gutters while preserving one main column", () => {
const layout = {
id: "layout",
name: "Base",
columnCount: 4,
leftGutterColumnCount: 3,
rightGutterColumnCount: 3,
breakpoint: 0,
} satisfies Board["layouts"][number];
expect(getBoardLaneColumnCount(layout, "left")).toBe(3);
expect(getBoardLaneColumnCount(layout, "main")).toBe(1);
expect(getBoardLaneColumnCount(layout, "right")).toBe(0);
});
test("rejects ambiguous duplicate roots", () => {
const board = new BoardMockBuilder()
.addSection(new EmptySectionMockBuilder({ id: "main-a" }).build())
.addSection(new EmptySectionMockBuilder({ id: "main-b" }).build())
.build();
expect(() => getRootSectionForLane(board, "main")).toThrow("multiple main canvas roots");
});
test("calculates a stable server height from the tallest visible lane", () => {
const layoutId = "layout";
const mainSectionId = "main";
const leftSectionId = "left";
const dynamicSectionId = "dynamic";
const board = new BoardMockBuilder()
.addSection(new EmptySectionMockBuilder({ id: mainSectionId }).build())
.addSection(new EmptySectionMockBuilder({ id: leftSectionId, xOffset: -1 }).build())
.addSection(
new DynamicSectionMockBuilder({
id: dynamicSectionId,
collapsed: true,
options: { collapsible: true },
})
.addLayout({
layoutId,
parentSectionId: mainSectionId,
xOffset: 0,
yOffset: 0,
width: 2,
height: 3,
})
.build(),
)
.addItem(
new ItemMockBuilder()
.addLayout({
layoutId,
sectionId: mainSectionId,
xOffset: 0,
yOffset: 3,
width: 1,
height: 1,
})
.build(),
)
.addItem(
new ItemMockBuilder()
.addLayout({
layoutId,
sectionId: leftSectionId,
xOffset: 0,
yOffset: 0,
width: 1,
height: 4,
})
.build(),
)
.build();
board.layouts = [
{
id: layoutId,
name: "Base",
columnCount: 8,
leftGutterColumnCount: 1,
rightGutterColumnCount: 0,
breakpoint: 0,
},
];
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(872);
});
});
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import {
getLayoutRowCount,
getLogicalItemStyle,
getLogicalTrackSize,
normalizeGridPlacement,
} from "../index";
describe("fixed dashboard geometry", () => {
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
expect(getLogicalTrackSize(1)).toBe(200);
expect(getLogicalTrackSize(2)).toBe(424);
expect(getLogicalTrackSize(3)).toBe(648);
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
position: "absolute",
left: 224,
top: 448,
width: 424,
height: 648,
});
});
test("normalizes invalid bounds and derives the required row count", () => {
const placement = normalizeGridPlacement({ id: "wide", x: 7, y: -2, w: 8, h: 2 }, 4);
expect(placement).toEqual({ id: "wide", x: 0, y: 0, w: 4, h: 2 });
expect(getLayoutRowCount([placement, { x: 0, y: 4, w: 1, h: 3 }])).toBe(7);
});
});
@@ -0,0 +1,100 @@
import { describe, expect, test } from "vitest";
import {
doGridPlacementsOverlap,
getCollapsedDisplayLayout,
placeGridItem,
reflowVerticalLayout,
sortGridPlacementsForReadingOrder,
} from "../index";
import type { GridPlacement } from "../index";
describe("dashboard layout reflow", () => {
test("compacts each horizontal lane and removes collisions", () => {
const result = reflowVerticalLayout(
[
{ id: "a", x: 0, y: 0, w: 1, h: 1 },
{ id: "b", x: 0, y: 3, w: 1, h: 1 },
{ id: "c", x: 1, y: 3, w: 1, h: 1 },
],
{ columnCount: 2 },
);
expect(result).toEqual([
{ id: "a", x: 0, y: 0, w: 1, h: 1 },
{ id: "b", x: 0, y: 1, w: 1, h: 1 },
{ id: "c", x: 1, y: 0, w: 1, h: 1 },
]);
expect(hasOverlap(result)).toBe(false);
});
test("shrinks a collapsed inline container and pulls neighbors up", () => {
const result = getCollapsedDisplayLayout(
[
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
{ id: "below", x: 0, y: 3, w: 2, h: 1 },
{ id: "other-lane", x: 2, y: 4, w: 1, h: 1 },
],
{
columnCount: 3,
collapsedItemIds: new Set(["section"]),
},
);
expect(result).toEqual([
{ id: "section", x: 0, y: 0, w: 2, h: 1 },
{ id: "below", x: 0, y: 1, w: 2, h: 1 },
{ id: "other-lane", x: 2, y: 0, w: 1, h: 1 },
]);
});
test("pins a moved or expanded item and pushes collisions out of the way", () => {
const result = placeGridItem(
[
{ id: "section", x: 0, y: 0, w: 2, h: 1 },
{ id: "neighbor", x: 0, y: 1, w: 2, h: 1 },
{ id: "side", x: 2, y: 2, w: 1, h: 1 },
],
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
3,
);
expect(result).toEqual([
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
{ id: "neighbor", x: 0, y: 3, w: 2, h: 1 },
{ id: "side", x: 2, y: 0, w: 1, h: 1 },
]);
expect(hasOverlap(result)).toBe(false);
});
test("is deterministic regardless of input array order", () => {
const first = [
{ id: "b", x: 0, y: 2, w: 1, h: 1 },
{ id: "a", x: 0, y: 2, w: 1, h: 1 },
{ id: "c", x: 1, y: 8, w: 1, h: 1 },
];
const second = first.toReversed();
expect(byId(reflowVerticalLayout(first, { columnCount: 2 }))).toEqual(
byId(reflowVerticalLayout(second, { columnCount: 2 })),
);
});
test("provides row-major DOM order for the read-only renderer", () => {
const items = [
{ id: "last", x: 0, y: 2, w: 1, h: 1 },
{ id: "right", x: 1, y: 0, w: 1, h: 1 },
{ id: "left", x: 0, y: 0, w: 1, h: 1 },
];
expect(sortGridPlacementsForReadingOrder(items).map((item) => item.id)).toEqual(["left", "right", "last"]);
});
});
const hasOverlap = (placements: readonly GridPlacement[]) =>
placements.some((placement, index) =>
placements.slice(index + 1).some((otherPlacement) => doGridPlacementsOverlap(placement, otherPlacement)),
);
const byId = (placements: readonly GridPlacement[]) =>
Object.fromEntries(placements.map((placement) => [placement.id, placement]));
@@ -0,0 +1,60 @@
import { describe, expect, test } from "vitest";
import { calculateBoardCanvasScale, calculateBoardUiScale, MIN_ACCESSIBLE_CANVAS_SCALE } from "../scaled-board-canvas";
describe("board canvas scale policy", () => {
test("fits the logical canvas exactly when the viewport can preserve the minimum scale", () => {
const scale = calculateBoardCanvasScale(840, 1000);
expect(scale).toBe(0.84);
expect(1000 * scale).toBe(840);
});
test("scales up uniformly on a larger screen", () => {
expect(calculateBoardCanvasScale(1800, 1000)).toBe(1.8);
});
test("stops counter-scaling at 0.75 and lets the canvas overflow", () => {
const availableWidth = 600;
const logicalWidth = 1000;
const scale = calculateBoardCanvasScale(availableWidth, logicalWidth);
expect(scale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
expect(logicalWidth * scale).toBeGreaterThan(availableWidth);
});
test("keeps content materially magnified at 200% effective zoom", () => {
const logicalWidth = 1696;
const normalAvailableWidth = 1872;
const normalScale = calculateBoardCanvasScale(normalAvailableWidth, logicalWidth);
const zoomedScale = calculateBoardCanvasScale(normalAvailableWidth / 2, logicalWidth);
const normalPaintedCellSize = 200 * normalScale;
const zoomedPaintedCellSize = 200 * zoomedScale * 2;
expect(zoomedScale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
expect(zoomedPaintedCellSize).toBeGreaterThan(normalPaintedCellSize * 1.25);
});
test.each([
[0, 1000],
[1000, 0],
[Number.NaN, 1000],
[1000, Number.POSITIVE_INFINITY],
])("uses a stable initial scale for unavailable geometry (%s, %s)", (availableWidth, logicalWidth) => {
expect(calculateBoardCanvasScale(availableWidth, logicalWidth)).toBe(1);
});
test("keeps UI typography and density at 100% while fitting the canvas down", () => {
expect(calculateBoardUiScale(0.75)).toBeCloseTo(4 / 3);
expect(calculateBoardUiScale(0.84)).toBeCloseTo(1 / 0.84);
expect(calculateBoardUiScale(1)).toBe(1);
expect(calculateBoardUiScale(1.5)).toBe(1);
});
test.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
"uses stable UI density for an invalid canvas scale (%s)",
(scale) => {
expect(calculateBoardUiScale(scale)).toBe(1);
},
);
});
@@ -0,0 +1,57 @@
import { describe, expect, test } from "vitest";
import {
getEditableCanvasAttributes,
getEditableGridCellAttributes,
getReadonlyCanvasAttributes,
getReadonlyGridItemAttributes,
} from "../index";
describe("dashboard layout semantics", () => {
const placement = { id: "weather", x: 1, y: 2, w: 3, h: 2 };
test("keeps read-only mode a native labelled region", () => {
expect(getReadonlyCanvasAttributes({ label: "Home dashboard" })).toEqual({
role: "region",
"aria-label": "Home dashboard",
"data-grid-readonly": "true",
});
expect(getReadonlyGridItemAttributes(placement)).toEqual({
"data-grid-id": "weather",
"data-grid-x": 1,
"data-grid-y": 2,
"data-grid-w": 3,
"data-grid-h": 2,
});
});
test("keeps edit mode a labelled region with spatial item metadata", () => {
expect(
getEditableCanvasAttributes({
label: "Edit home dashboard",
columnCount: 12,
rowCount: 8,
}),
).toEqual({
role: "region",
"aria-label": "Edit home dashboard",
"data-grid-editable": "true",
});
expect(
getEditableGridCellAttributes({
label: "Weather widget",
placement,
}),
).toEqual({
role: "group",
"aria-label": "Weather widget",
tabIndex: 0,
"data-grid-id": "weather",
"data-grid-x": 1,
"data-grid-y": 2,
"data-grid-w": 3,
"data-grid-h": 2,
});
});
});
@@ -0,0 +1,10 @@
export interface GridCoordinates {
x: number;
y: number;
w: number;
h: number;
}
export interface GridPlacement extends GridCoordinates {
id: string;
}
@@ -1,65 +0,0 @@
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}`;
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");
}
},
});
return (
<Card style={{ "--opacity": board.opacity / 100 }} radius={board.itemRadius} p={0} className={classes.itemCard}>
<Stack>
<Group wrap="nowrap" gap="sm">
<UnstyledButton w="100%" p="sm" onClick={toggle}>
<Group wrap="nowrap">
{opened ? <IconChevronUp size={20} /> : <IconChevronDown size={20} />}
<Title order={3}>{section.name}</Title>
</Group>
</UnstyledButton>
<CategoryMenu category={section} />
</Group>
<Collapse expanded={opened} p="sm" pt={0}>
<GridStack section={section} />
</Collapse>
</Stack>
</Card>
);
};
@@ -1,54 +0,0 @@
import type { RouterOutputs } from "@homarr/api";
import type { CategorySection } from "~/app/[locale]/boards/_types";
export interface MoveCategoryInput {
id: string;
direction: "up" | "down";
}
export const moveCategoryCallback =
(input: MoveCategoryInput) =>
(previous: RouterOutputs["board"]["getHomeBoard"]): RouterOutputs["board"]["getHomeBoard"] => {
const currentCategory = previous.sections.find(
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
);
if (!currentCategory) {
return previous;
}
if (currentCategory.yOffset === 1 && input.direction === "up") {
return previous;
}
if (currentCategory.yOffset === previous.sections.length - 2 && input.direction === "down") {
return previous;
}
return {
...previous,
sections: previous.sections.map((section) => {
if (section.kind !== "category" && section.kind !== "empty") {
return section;
}
const offset = input.direction === "up" ? -2 : 2;
// Move category and empty section
if (section.yOffset === currentCategory.yOffset || section.yOffset === currentCategory.yOffset + 1) {
return {
...section,
yOffset: section.yOffset + offset,
};
}
if (
section.yOffset === currentCategory.yOffset + offset ||
section.yOffset === currentCategory.yOffset + offset + 1
) {
return {
...section,
yOffset: section.yOffset - offset,
};
}
return section;
}),
};
};
@@ -1,150 +0,0 @@
import { getBoardLayouts } from "@homarr/boards/context";
import type { Board, CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
export interface RemoveCategoryInput {
id: string;
}
export const removeCategoryCallback =
(input: RemoveCategoryInput) =>
(previous: Board): Board => {
const currentCategory = previous.sections.find(
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
);
if (!currentCategory) {
return previous;
}
const emptySectionsAbove = previous.sections.filter(
(section): section is EmptySection => section.kind === "empty" && section.yOffset < currentCategory.yOffset,
);
const aboveSection = emptySectionsAbove.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset).at(0);
const emptySectionsBelow = previous.sections.filter(
(section): section is EmptySection => section.kind === "empty" && section.yOffset > currentCategory.yOffset,
);
const removedSection = emptySectionsBelow.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset).at(0);
if (!aboveSection || !removedSection) {
return previous;
}
const aboveYOffsets = getBoardLayouts(previous).map((layoutId) => {
return {
layoutId,
yOffset: Math.max(
calculateYHeightWithOffsetForItemLayouts(previous, { sectionId: aboveSection.id, layoutId }),
calculateYHeightWithOffsetForDynamicSectionLayouts(previous.sections, {
sectionId: aboveSection.id,
layoutId,
}),
),
};
});
const categoryYOffsets = getBoardLayouts(previous).map((layoutId) => {
return {
layoutId,
yOffset: Math.max(
calculateYHeightWithOffsetForItemLayouts(previous, { sectionId: currentCategory.id, layoutId }),
calculateYHeightWithOffsetForDynamicSectionLayouts(previous.sections, {
sectionId: currentCategory.id,
layoutId,
}),
),
};
});
return {
...previous,
sections: previous.sections
.filter((section) => section.id !== currentCategory.id && section.id !== removedSection.id)
.map((section) =>
section.kind === "dynamic"
? {
...section,
layouts: section.layouts.map((layout) => {
const aboveYOffset = aboveYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
const categoryYOffset =
categoryYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
if (layout.parentSectionId === currentCategory.id) {
return {
...layout,
yOffset: layout.yOffset + aboveYOffset,
parentSectionId: aboveSection.id,
};
}
if (layout.parentSectionId === removedSection.id) {
return {
...layout,
yOffset: layout.yOffset + aboveYOffset + categoryYOffset,
parentSectionId: aboveSection.id,
};
}
return layout;
}),
}
: section,
),
items: previous.items.map((item) => ({
...item,
layouts: item.layouts.map((layout) => {
const aboveYOffset = aboveYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
const categoryYOffset = categoryYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
if (layout.sectionId === currentCategory.id) {
return {
...layout,
yOffset: layout.yOffset + aboveYOffset,
sectionId: aboveSection.id,
};
}
if (layout.sectionId === removedSection.id) {
return {
...layout,
yOffset: layout.yOffset + aboveYOffset + categoryYOffset,
sectionId: aboveSection.id,
};
}
return layout;
}),
})),
};
};
const calculateYHeightWithOffsetForDynamicSectionLayouts = (
sections: Section[],
{ sectionId, layoutId }: { sectionId: string; layoutId: string },
) => {
return sections
.filter((section) => section.kind === "dynamic")
.map((section) => section.layouts.find((layout) => layout.layoutId === layoutId))
.filter((layout) => layout !== undefined)
.filter((layout) => layout.parentSectionId === sectionId)
.reduce((acc, layout) => {
const yHeightWithOffset = layout.yOffset + layout.height;
if (yHeightWithOffset > acc) return yHeightWithOffset;
return acc;
}, 0);
};
const calculateYHeightWithOffsetForItemLayouts = (
board: Board,
{ sectionId, layoutId }: { sectionId: string; layoutId: string },
) =>
board.items
.map((item) => item.layouts.find((layout) => layout.layoutId === layoutId))
.filter((layout) => layout !== undefined)
.filter((layout) => layout.sectionId === sectionId)
.reduce((acc, layout) => {
const yHeightWithOffset = layout.yOffset + layout.height;
if (yHeightWithOffset > acc) return yHeightWithOffset;
return acc;
}, 0);
@@ -1,79 +0,0 @@
import { describe, expect, test } from "vitest";
import type { Section } from "~/app/[locale]/boards/_types";
import { moveCategoryCallback } from "../move-category";
describe("Move Category", () => {
test.each([
[3, [0, 3, 4, 1, 2, 5, 6]],
[5, [0, 1, 2, 5, 6, 3, 4]],
])("should move category up", (moveId, expectedOrder) => {
const sections = createSections(3);
const input = {
id: moveId.toString(),
direction: "up" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
});
test.each([
[1, [0, 3, 4, 1, 2, 5, 6]],
[3, [0, 1, 2, 5, 6, 3, 4]],
])("should move category down", (moveId, expectedOrder) => {
const sections = createSections(3);
const input = {
id: moveId.toString(),
direction: "down" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
});
test("should not move category up if it is at the top", () => {
const sections = createSections(3);
const input = {
id: "1",
direction: "up" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
test("should not move category down if it is at the bottom", () => {
const sections = createSections(3);
const input = {
id: "5",
direction: "down" as const,
};
const result = moveCategoryCallback(input)({ sections } as never);
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
});
const createSections = (categoryCount: number) => {
return Array.from({ length: categoryCount * 2 + 1 }, (_, index) => ({
id: index.toString(),
kind: index % 2 === 1 ? ("category" as const) : ("empty" as const),
name: `Category ${index}`,
yOffset: index,
xOffset: 0,
collapsed: false,
items: [],
})) satisfies Section[];
};
const sortSections = (sections: Section[]) => {
return sections
.filter((section) => section.kind !== "dynamic")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
};
@@ -1,145 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import type { DynamicSection, Section } from "~/app/[locale]/boards/_types";
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
import { CategorySectionMockBuilder } from "~/components/board/items/actions/test/mocks/category-section-mock";
import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock";
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
import { removeCategoryCallback } from "../remove-category";
describe("Remove Category", () => {
test.each([
[3, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 5, 6], [3, 4], 2],
[5, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 3, 4], [5, 6], 4],
[1, [0, 1, 2, 3, 4, 5, 6], [0, 3, 4, 5, 6], [1, 2], 0],
[3, [0, 3, 6, 7, 8], [0, 7, 8], [3, 6], 0],
])(
"should remove category",
(removeId, initialYOffsets, expectedYOffsets, expectedRemovals, expectedLocationOfItems) => {
// Arrange
const layoutId = "1";
const input = removeId.toString();
const board = new BoardMockBuilder()
.addLayout({ id: layoutId })
.addSections(createSections(initialYOffsets))
.addItems(createSectionItems(initialYOffsets, layoutId))
.build();
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layoutId]);
// Act
const result = removeCategoryCallback({ id: input })(board);
// Assert
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual(expectedYOffsets);
expectedRemovals.forEach((expectedRemoval) => {
expect(result.sections.find((section) => section.id === expectedRemoval.toString())).toBeUndefined();
});
const aboveSectionItems = result.items.filter(
(item) => item.layouts[0]?.sectionId === expectedLocationOfItems.toString(),
);
expect(aboveSectionItems.map((item) => parseInt(item.id, 10))).toEqual(expect.arrayContaining(expectedRemovals));
},
);
test("should correctly move items to above empty section", () => {
// Arrange
const layoutId = "1";
const sectionIds = {
above: "2",
category: "3",
below: "4",
dynamic: "7",
};
const initialYOffsets = [0, 1, 2, 3, 4, 5, 6];
const board = new BoardMockBuilder()
.addLayout({ id: layoutId })
.addSections(createSections(initialYOffsets))
.addItems(createSectionItems([0, 1, 5, 6], layoutId)) // Only add items to other sections
.addDynamicSection(
new DynamicSectionMockBuilder({ id: sectionIds.dynamic })
.addLayout({ layoutId, parentSectionId: sectionIds.category, yOffset: 7, height: 3 })
.build(),
)
.addItem(new ItemMockBuilder({ id: "above-1" }).addLayout({ layoutId, sectionId: sectionIds.above }).build())
.addItem(
new ItemMockBuilder({ id: "above-2" })
.addLayout({ layoutId, sectionId: sectionIds.above, yOffset: 3, xOffset: 2, height: 2 })
.build(),
)
.addItem(
new ItemMockBuilder({ id: "category-1" }).addLayout({ layoutId, sectionId: sectionIds.category }).build(),
)
.addItem(
new ItemMockBuilder({ id: "category-2" })
.addLayout({ layoutId, sectionId: sectionIds.category, yOffset: 2, xOffset: 4, width: 4 })
.build(),
)
.addItem(
new ItemMockBuilder({ id: "below-1" }).addLayout({ layoutId, sectionId: sectionIds.below, xOffset: 5 }).build(),
)
.addItem(
new ItemMockBuilder({ id: "below-2" })
.addLayout({ layoutId, sectionId: sectionIds.below, yOffset: 1, xOffset: 1, height: 2 })
.build(),
)
.addItem(new ItemMockBuilder({ id: "dynamic-1" }).addLayout({ layoutId, sectionId: sectionIds.dynamic }).build())
.build();
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layoutId]);
// Act
const result = removeCategoryCallback({ id: sectionIds.category })(board);
// Assert
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 5, 6, 7]);
const aboveSectionItems = result.items.filter((item) => item.layouts[0]?.sectionId === sectionIds.above);
expect(aboveSectionItems.length).toBe(6);
expect(
aboveSectionItems
.map((item) => ({
...item,
...item.layouts[0]!,
}))
.sort((itemA, itemB) => itemA.yOffset - itemB.yOffset),
).toEqual([
expect.objectContaining({ id: "above-1", yOffset: 0, xOffset: 0 }),
expect.objectContaining({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
expect.objectContaining({ id: "category-1", yOffset: 5, xOffset: 0 }),
expect.objectContaining({ id: "category-2", yOffset: 7, xOffset: 4, width: 4 }),
expect.objectContaining({ id: "below-1", yOffset: 15, xOffset: 5 }),
expect.objectContaining({ id: "below-2", yOffset: 16, xOffset: 1, height: 2 }),
]);
const dynamicSection = result.sections.find((section): section is DynamicSection => section.id === "7")!;
expect(dynamicSection.layouts.at(0)?.yOffset).toBe(12);
expect(dynamicSection.layouts[0]?.parentSectionId).toBe("2");
});
});
const createSections = (initialYOffsets: number[]) => {
return initialYOffsets.map((yOffset, index) =>
index % 2 === 0
? new EmptySectionMockBuilder({
id: yOffset.toString(),
yOffset,
}).build()
: new CategorySectionMockBuilder({
id: yOffset.toString(),
yOffset,
}).build(),
) satisfies Section[];
};
const createSectionItems = (initialYOffsets: number[], layoutId: string) => {
return initialYOffsets.map((yOffset) =>
new ItemMockBuilder({ id: yOffset.toString() }).addLayout({ layoutId, sectionId: yOffset.toString() }).build(),
);
};
@@ -1,145 +0,0 @@
import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import { createId } from "@homarr/common";
import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
import type { MoveCategoryInput } from "./actions/move-category";
import { moveCategoryCallback } from "./actions/move-category";
import type { RemoveCategoryInput } from "./actions/remove-category";
import { removeCategoryCallback } from "./actions/remove-category";
interface AddCategory {
name: string;
yOffset: number;
}
interface RenameCategory {
id: string;
name: string;
}
export const useCategoryActions = () => {
const { updateBoard } = useUpdateBoard();
const addCategory = useCallback(
({ name, yOffset }: AddCategory) => {
if (yOffset <= -1) {
return;
}
updateBoard((previous) => ({
...previous,
sections: [
// Place sections before the new category
...previous.sections.filter(
(section) => (section.kind === "category" || section.kind === "empty") && section.yOffset < yOffset,
),
{
id: createId(),
name,
kind: "category",
yOffset,
xOffset: 0,
collapsed: false,
},
{
id: createId(),
kind: "empty",
yOffset: yOffset + 1,
xOffset: 0,
},
// Place sections after the new category
...previous.sections
.filter(
(section): section is CategorySection | EmptySection =>
(section.kind === "category" || section.kind === "empty") && section.yOffset >= yOffset,
)
.map((section) => ({
...section,
yOffset: section.yOffset + 2,
})),
...previous.sections.filter((section) => section.kind !== "category" && section.kind !== "empty"),
] satisfies Section[],
}));
},
[updateBoard],
);
const addCategoryToEnd = useCallback(
({ name }: { name: string }) => {
updateBoard((previous) => {
const lastSection = previous.sections
.filter(
(section): section is CategorySection | EmptySection =>
section.kind === "empty" || section.kind === "category",
)
.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)
.at(0);
if (!lastSection) return previous;
const lastYOffset = lastSection.yOffset;
return {
...previous,
sections: [
...previous.sections,
{
id: createId(),
name,
kind: "category",
yOffset: lastYOffset + 1,
xOffset: 0,
collapsed: false,
},
{
id: createId(),
kind: "empty",
yOffset: lastYOffset + 2,
xOffset: 0,
},
] satisfies Section[],
};
});
},
[updateBoard],
);
const renameCategory = useCallback(
({ id: categoryId, name }: RenameCategory) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((section) => {
if (section.kind !== "category") return section;
if (section.id !== categoryId) return section;
return {
...section,
name,
};
}),
}));
},
[updateBoard],
);
const moveCategory = useCallback(
(input: MoveCategoryInput) => {
updateBoard(moveCategoryCallback(input));
},
[updateBoard],
);
const removeCategory = useCallback(
(input: RemoveCategoryInput) => {
updateBoard(removeCategoryCallback(input));
},
[updateBoard],
);
return {
addCategory,
addCategoryToEnd,
renameCategory,
moveCategory,
removeCategory,
};
};
@@ -1,45 +0,0 @@
import { Stack, TextInput } from "@mantine/core";
import { z } from "zod/v4";
import { useZodForm } from "@homarr/form";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface Category {
id: string;
name: string;
}
interface InnerProps {
submitLabel: string;
category: Category;
onSuccess: (category: Category) => void;
}
export const CategoryEditModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const form = useZodForm(z.object({ name: z.string().min(1) }), {
initialValues: {
name: innerProps.category.name,
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
void innerProps.onSuccess({
...innerProps.category,
name: values.name,
});
actions.closeModal();
})}
>
<Stack>
<TextInput label={t("section.category.field.name.label")} data-autofocus {...form.getInputProps("name")} />
<ModalFormFooter onCancel={actions.closeModal} submitLabel={innerProps.submitLabel} />
</Stack>
</form>
);
}).withOptions({
size: modalSizeForm,
});
@@ -1,146 +0,0 @@
import { useCallback } from "react";
import { fetchApi } from "@homarr/api/client";
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type { CategorySection } from "~/app/[locale]/boards/_types";
import { useCategoryActions } from "./category-actions";
import { CategoryEditModal } from "./category-edit-modal";
import { filterByItemKind } from "./filter";
export const useCategoryMenuActions = (category: CategorySection) => {
const { openModal } = useModalAction(CategoryEditModal);
const { openConfirmModal } = useConfirmModal();
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
const t = useI18n();
const board = useRequiredBoard();
const createCategoryAtYOffset = useCallback(
(position: number) => {
openModal(
{
category: {
id: createId(),
name: t("section.category.create.title"),
},
onSuccess: (category) => {
addCategory({
name: category.name,
yOffset: position,
});
},
submitLabel: t("section.category.create.submit"),
},
{
title: (t) => t("section.category.create.title"),
},
);
},
[addCategory, t, openModal],
);
// creates a new category above the current
const addCategoryAbove = useCallback(() => {
const aboveYOffset = category.yOffset;
createCategoryAtYOffset(aboveYOffset);
}, [category.yOffset, createCategoryAtYOffset]);
// creates a new category below the current
const addCategoryBelow = useCallback(() => {
const belowYOffset = category.yOffset + 2;
createCategoryAtYOffset(belowYOffset);
}, [category.yOffset, createCategoryAtYOffset]);
const moveCategoryUp = useCallback(() => {
moveCategory({
id: category.id,
direction: "up",
});
}, [category.id, moveCategory]);
const moveCategoryDown = useCallback(() => {
moveCategory({
id: category.id,
direction: "down",
});
}, [category.id, moveCategory]);
// Removes the current category
const remove = useCallback(() => {
openConfirmModal({
title: t("section.category.remove.title"),
children: t("section.category.remove.message", {
name: category.name,
}),
onConfirm: () => {
removeCategory({
id: category.id,
});
},
});
}, [category.id, category.name, removeCategory, t, openConfirmModal]);
const edit = useCallback(() => {
openModal(
{
category,
submitLabel: t("section.category.edit.submit"),
onSuccess: (category) => {
renameCategory({
id: category.id,
name: category.name,
});
},
},
{
title: (t) => t("section.category.edit.title"),
},
);
}, [category, openModal, renameCategory, t]);
const settings = useSettings();
const openAllInNewTabs = useCallback(async () => {
const currentLayoutId = getCurrentLayout(board);
const appIds = (
await filterByItemKind(
board.items.filter(
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
),
settings,
"app",
)
).map((item) => {
return item.options.appId;
});
const apps = await fetchApi.app.byIds.query(appIds);
const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0);
for (const app of appsWithUrls) {
const openedWindow = window.open(app.href ?? undefined);
if (openedWindow) {
continue;
}
openConfirmModal({
title: t("section.category.openAllInNewTabs.title"),
children: t("section.category.openAllInNewTabs.text"),
});
break;
}
}, [category, board, t, openConfirmModal, settings]);
return {
addCategoryAbove,
addCategoryBelow,
moveCategoryUp,
moveCategoryDown,
remove,
edit,
openAllInNewTabs,
};
};
@@ -1,128 +0,0 @@
"use client";
import React, { useMemo } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import {
IconDotsVertical,
IconEdit,
IconExternalLink,
IconRowInsertBottom,
IconRowInsertTop,
IconTransitionBottom,
IconTransitionTop,
IconTrash,
} from "@tabler/icons-react";
import { useEditMode } from "@homarr/boards/edit-mode";
import type { MaybePromise } from "@homarr/common/types";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { CategorySection } from "~/app/[locale]/boards/_types";
import { useCategoryMenuActions } from "./category-menu-actions";
interface Props {
category: CategorySection;
}
export const CategoryMenu = ({ category }: Props) => {
const actions = useActions(category);
const t = useScopedI18n("section.category");
return (
<Menu>
<Menu.Target>
<ActionIcon mr="sm" variant="transparent" aria-label={t("menu.label.settings")}>
<IconDotsVertical size={20} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{actions.map((action) => {
return (
<React.Fragment key={action.label}>
{"group" in action && <Menu.Label>{t(action.group)}</Menu.Label>}
<Menu.Item
leftSection={<action.icon size="1rem" />}
onClick={action.onClick}
color={"color" in action ? action.color : undefined}
>
{t(action.label)}
</Menu.Item>
</React.Fragment>
);
})}
</Menu.Dropdown>
</Menu>
);
};
const useActions = (category: CategorySection) => {
const [isEditMode] = useEditMode();
const editModeActions = useEditModeActions(category);
const nonEditModeActions = useNonEditModeActions(category);
return useMemo(
() => (isEditMode ? editModeActions : nonEditModeActions),
[isEditMode, editModeActions, nonEditModeActions],
);
};
const useEditModeActions = (category: CategorySection) => {
const { addCategoryAbove, addCategoryBelow, moveCategoryUp, moveCategoryDown, edit, remove } =
useCategoryMenuActions(category);
return [
{
icon: IconEdit,
label: "action.edit",
onClick: edit,
},
{
icon: IconTrash,
color: "red",
label: "action.remove",
onClick: remove,
},
{
group: "menu.label.changePosition",
icon: IconTransitionTop,
label: "action.moveUp",
onClick: moveCategoryUp,
},
{
icon: IconTransitionBottom,
label: "action.moveDown",
onClick: moveCategoryDown,
},
{
group: "menu.label.create",
icon: IconRowInsertTop,
label: "action.createAbove",
onClick: addCategoryAbove,
},
{
icon: IconRowInsertBottom,
label: "action.createBelow",
onClick: addCategoryBelow,
},
] as const satisfies ActionDefinition[];
};
const useNonEditModeActions = (category: CategorySection) => {
const { openAllInNewTabs } = useCategoryMenuActions(category);
return [
{
icon: IconExternalLink,
label: "action.openAllInNewTabs",
onClick: openAllInNewTabs,
},
] as const satisfies ActionDefinition[];
};
interface ActionDefinition {
icon: TablerIcon;
label: string;
onClick: () => MaybePromise<void>;
color?: string;
group?: string;
}
@@ -1,33 +1,19 @@
import { useMemo } from "react";
import type { GridItemHTMLElement } from "@homarr/gridstack";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
import { BoardDynamicSection } from "./dynamic-section";
import { GridStackItem } from "./gridstack/gridstack-item";
import { BoardContainerSection } from "./container-section";
import { FixedGridItem } from "./grid/fixed-grid-item";
import { useSectionContext } from "./section-context";
import { useSectionItems } from "./use-section-items";
export const SectionContent = () => {
const { innerSections, items, refs } = useSectionContext();
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session) && isEditMode,
});
const { innerSections, integrations, items } = useSectionContext();
/**
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
* THE ITEMS RENDERED WITH GRIDSTACK, OTHERWISE THE ITEMS WILL BE MIXED UP
* @see https://github.com/homarr-labs/homarr/pull/1770
*/
/** Keep portal content order deterministic across static and edit renderers. */
const sortedItems = useMemo(() => {
return [...items, ...innerSections].toSorted((itemA, itemB) => {
if (itemA.yOffset === itemB.yOffset) {
@@ -41,57 +27,39 @@ export const SectionContent = () => {
return (
<>
{sortedItems.map((item) => (
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
<SectionContentItem key={item.id} item={item} integrations={integrations} />
))}
</>
);
};
interface ItemProps {
item: DynamicSectionItem | SectionItem;
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
item: ContainerSectionItem | SectionItem;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
const Item = ({ item, innerRef, integrations }: ItemProps) => {
export const SectionContentItem = ({ item, integrations }: ItemProps) => {
const minWidth = useMinSize(item, "x");
const minHeight = useMinSize(item, "y");
return (
<GridStackItem
key={item.id}
innerRef={innerRef}
width={item.width}
height={item.height}
xOffset={item.xOffset}
yOffset={item.yOffset}
kind={item.kind}
id={item.id}
type={item.type}
minWidth={minWidth}
minHeight={minHeight}
>
<FixedGridItem key={item.id} item={item} minWidth={minWidth} minHeight={minHeight}>
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
</GridStackItem>
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardContainerSection section={item} />}
</FixedGridItem>
);
};
/**
* Calculates the min width / height of a section by taking the maximum of
* the sum of the offset and size of all items and dynamic sections inside.
* @param direction either "x" or "y"
* @param items items of the section
* @param sections sections of the board to look for dynamic sections
* @param parentSectionId the id of the section we want to calculate the min size for
* @returns the min size
* Calculates a container's minimum width or height from its direct content.
*/
const useMinSize = (item: DynamicSectionItem | SectionItem, direction: "x" | "y") => {
export const useMinSize = (item: ContainerSectionItem | SectionItem, direction: "x" | "y") => {
const { items, innerSections } = useSectionItems(item.id);
if (item.type === "item") return undefined;
const size = direction === "x" ? "width" : "height";
return Math.max(
...items.map((item) => item[`${direction}Offset`] + item[size]),
...innerSections.map((item) => item[`${direction}Offset`] + item[size]),
1,
...items.map((childItem) => childItem[`${direction}Offset`] + childItem[size]),
...innerSections.map((childSection) => childSection[`${direction}Offset`] + childSection[size]),
);
};
@@ -1,25 +1,52 @@
import { Badge, Box, Card } from "@mantine/core";
import dynamic from "next/dynamic";
import { ActionIcon, Badge, Box, Card } from "@mantine/core";
import { IconChevronDown, IconChevronUp, IconExternalLink } from "@tabler/icons-react";
import combineClasses from "clsx";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
import { GridStack } from "./gridstack/gridstack";
import { SectionGrid } from "./grid/section-grid";
import { useSectionCollapse } from "./section-collapse";
import { useOpenSectionApps } from "./use-open-section-apps";
import classes from "./item.module.css";
const BoardDynamicSectionMenu = dynamic(
() => import("./dynamic/dynamic-menu").then((module) => module.BoardDynamicSectionMenu),
{ ssr: false },
);
interface Props {
section: DynamicSectionItem;
}
export const BoardDynamicSection = ({ section }: Props) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const [isEditMode] = useEditMode();
const t = useScopedI18n("section.dynamic");
const tAll = useI18n();
const options = section.options;
const { open: openAllInNewTabs, isLoading: areAppsLoading } = useOpenSectionApps(
section.id,
options.showOpenAll && !isEditMode,
);
const { isVisuallyCollapsed, toggle } = useSectionCollapse({
sectionId: section.id,
collapsible: options.collapsible,
});
const label = options.title || t("action.create");
const contentId = `board-section-${section.id}-content`;
const labelLeft = !isEditMode && options.collapsible ? 40 : 10;
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
return (
<Box
className="grid-stack-item-content"
data-grid-item-content
w="100%"
h="100%"
style={{
overflow: "visible",
}}
@@ -38,24 +65,65 @@ export const BoardDynamicSection = ({ section }: Props) => {
radius={board.itemRadius}
p={0}
>
{options.title && (
{options.showLabel && options.title && (
<Badge
pos="absolute"
top={-15}
left={10}
top={6}
left={labelLeft}
maw={`calc(100% - ${labelLeft + labelRight}px)`}
size="md"
radius={board.itemRadius}
color="var(--background-color)"
c="var(--mantine-color-text)"
bd="1px solid var(--border-color)"
style={{ zIndex: 9, pointerEvents: "none" }}
>
{options.title}
</Badge>
)}
{/* Use unique key by layout to reinitialize gridstack */}
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
{options.collapsible && !isEditMode && (
<ActionIcon
pos="absolute"
top={4}
left={4}
style={{ zIndex: 10 }}
variant="default"
radius="xl"
onClick={toggle}
aria-expanded={!isVisuallyCollapsed}
aria-controls={contentId}
aria-label={`${isVisuallyCollapsed ? t("action.expand") : t("action.collapse")}: ${label}`}
>
{isVisuallyCollapsed ? <IconChevronDown size={16} /> : <IconChevronUp size={16} />}
</ActionIcon>
)}
{options.showOpenAll && !isEditMode && (
<ActionIcon
pos="absolute"
top={4}
right={4}
style={{ zIndex: 10 }}
variant="default"
radius="xl"
loading={areAppsLoading}
onClick={openAllInNewTabs}
aria-label={tAll("section.action.openAllInNewTabsFor", { name: label })}
>
<IconExternalLink size={16} />
</ActionIcon>
)}
<Box id={contentId} h="100%" hidden={isVisuallyCollapsed}>
{!isVisuallyCollapsed && (
<SectionGrid
section={section}
columnCount={section.width}
requestedRowCount={section.height}
label={label}
/>
)}
</Box>
</Card>
<BoardDynamicSectionMenu section={section} />
{isEditMode && <BoardDynamicSectionMenu section={section} />}
</Box>
);
};
@@ -1,27 +1,30 @@
import { getBoardLayouts } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { getRootSectionLane } from "@homarr/definitions";
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
import type { Board, DynamicSection, DynamicSectionLayout, EmptySection } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount } from "~/components/board/layout";
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
import { getSectionElements } from "~/components/board/items/actions/section-elements";
export const addDynamicSectionCallback = () => (board: Board) => {
const firstSection = board.sections
.filter((section) => section.kind === "empty")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.filter(
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
)
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) return board;
const sectionLayouts = createDynamicSectionLayouts(board, firstSection);
const newSection = {
id: createId(),
kind: "dynamic",
options: {
title: "",
borderColor: "",
customCssClasses: [],
},
layouts: createDynamicSectionLayouts(board, firstSection),
collapsed: false,
options: dynamicSectionOptionsSchema.parse(undefined),
layouts: sectionLayouts,
} satisfies DynamicSection;
return {
@@ -36,9 +39,14 @@ const createDynamicSectionLayouts = (board: Board, currentSection: EmptySection)
return layouts.map((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : 1;
const size = {
width: Math.min(4, columnCount),
height: 3,
};
const emptyPosition = boardLayout
? getFirstEmptyPosition(elements, boardLayout.columnCount)
? getFirstEmptyPosition(elements, columnCount, undefined, size)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) {
@@ -46,8 +54,7 @@ const createDynamicSectionLayouts = (board: Board, currentSection: EmptySection)
}
return {
width: 1,
height: 1,
...size,
...emptyPosition,
parentSectionId: currentSection.id,
layoutId,
@@ -24,7 +24,9 @@ export const useDynamicSectionActions = () => {
({ itemId, newOptions }: UpdateDynamicOptions) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((item) => (item.id !== itemId ? item : { ...item, options: newOptions })),
sections: previous.sections.map((item) =>
item.id !== itemId || item.kind !== "dynamic" ? item : { ...item, options: newOptions },
),
}));
},
[updateBoard],
@@ -1,6 +1,6 @@
"use client";
import { CloseButton, ColorInput, Stack, TextInput, useMantineTheme } from "@mantine/core";
import { CloseButton, ColorInput, Stack, Switch, TextInput, useMantineTheme } from "@mantine/core";
import type { z } from "zod/v4";
import { useZodForm } from "@homarr/form";
@@ -18,7 +18,7 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
const t = useI18n();
const theme = useMantineTheme();
const form = useZodForm(dynamicSectionOptionsSchema, {
const form = useZodForm(dynamicSectionOptionsSchema.unwrap(), {
mode: "controlled",
initialValues: { ...innerProps.value },
});
@@ -32,6 +32,21 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
>
<Stack>
<TextInput label={t("section.dynamic.option.title.label")} data-autofocus {...form.getInputProps("title")} />
<Switch
label={t("section.option.showLabel.label")}
description={t("section.option.showLabel.description")}
{...form.getInputProps("showLabel", { type: "checkbox" })}
/>
<Switch
label={t("section.option.collapsible.label")}
description={t("section.option.collapsible.description")}
{...form.getInputProps("collapsible", { type: "checkbox" })}
/>
<Switch
label={t("section.option.showOpenAll.label")}
description={t("section.option.showOpenAll.description")}
{...form.getInputProps("showOpenAll", { type: "checkbox" })}
/>
<TextMultiSelect
label={t("section.dynamic.option.customCssClasses.label")}
{...form.getInputProps("customCssClasses")}
@@ -1,11 +1,13 @@
import { ActionIcon, Menu } from "@mantine/core";
import { IconDotsVertical, IconPencil, IconTrash } from "@tabler/icons-react";
import { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import { useOpenItemMoveModal } from "../../items/item-move-modal";
import { useSectionContext } from "../section-context";
import { useDynamicSectionActions } from "./dynamic-actions";
import { DynamicSectionEditModal } from "./dynamic-edit-modal";
@@ -14,9 +16,13 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
const tDynamic = useScopedI18n("section.dynamic");
const tItem = useScopedI18n("item");
const { openModal } = useModalAction(DynamicSectionEditModal);
const openMoveModal = useOpenItemMoveModal();
const { updateDynamicSection, removeDynamicSection } = useDynamicSectionActions();
const { openConfirmModal } = useConfirmModal();
const [isEditMode] = useEditMode();
const { section: parentSection } = useSectionContext();
const label = section.options.title || tDynamic("action.create");
const menuRightOffset = parentSection.kind === "dynamic" ? 44 : 4;
if (!isEditMode) return null;
@@ -50,11 +56,11 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
radius={"xl"}
pos="absolute"
top={4}
right={4}
right={menuRightOffset}
style={{ zIndex: 10 }}
aria-label={tItem("menu.label.settings")}
aria-label={tItem("menu.label.settingsFor", { name: label })}
>
<IconDotsVertical size={"1rem"} />
<IconLayoutKanban size={"1rem"} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
@@ -62,6 +68,17 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
{tItem("action.edit")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowsMove size={16} />}
onClick={() =>
openMoveModal({
entry: section,
sourceSectionId: section.parentSectionId,
})
}
>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Divider />
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
@@ -1,25 +1,32 @@
import combineClasses from "clsx";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useScopedI18n } from "@homarr/translation/client";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { GridStack } from "./gridstack/gridstack";
import { SectionGrid } from "./grid/section-grid";
import { useSectionItems } from "./use-section-items";
interface Props {
section: EmptySection;
columnCount: number;
requestedRowCount: number;
railPlacement?: "main" | "left" | "right";
}
export const BoardEmptySection = ({ section }: Props) => {
export const BoardEmptySection = ({ section, columnCount, requestedRowCount, railPlacement = "main" }: Props) => {
const { items, innerSections } = useSectionItems(section.id);
const totalLength = items.length + innerSections.length;
const [isEditMode] = useEditMode();
const t = useScopedI18n("board.landmark");
if (totalLength === 0 && !isEditMode && requestedRowCount === 0) return null;
return (
<GridStack
<SectionGrid
section={section}
style={{ transitionDuration: "0s" }}
className={combineClasses("min-row", totalLength > 0 || isEditMode ? undefined : "grid-stack-empty-wrapper")}
columnCount={columnCount}
requestedRowCount={requestedRowCount}
label={t("items")}
railPlacement={railPlacement}
/>
);
};
@@ -0,0 +1,201 @@
"use client";
import type { FocusEvent, KeyboardEvent, PropsWithChildren } from "react";
import { useId, useState } from "react";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
import { useCurrentLayout } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useI18n } from "@homarr/translation/client";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import {
getEditableGridCellAttributes,
getLayoutRowCount,
getLogicalItemStyle,
getReadonlyGridItemAttributes,
placeGridItem,
} from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import classes from "./section-grid.module.css";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions";
interface FixedGridItemProps {
item: DynamicSectionItem | SectionItem;
minWidth?: number;
minHeight?: number;
}
export const FixedGridItem = ({
item,
minWidth = 1,
minHeight = 1,
children,
}: PropsWithChildren<FixedGridItemProps>) => {
const [isEditMode] = useEditMode();
const currentLayoutId = useCurrentLayout();
const t = useI18n();
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
const { commitSectionGrid } = useGridLayoutActions();
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
const instructionsId = useId();
const placement = toPlacement(item);
const displayName =
item.type === "item"
? item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`)
: item.options.title.trim() || t("section.dynamic.untitled");
const accessibleLabel = t("item.moveResize.entryLabel", {
name: displayName,
column: String(placement.x + 1),
row: String(placement.y + 1),
});
const setKeyboardEditing = (next: boolean) => {
setIsKeyboardEditing(next);
announce(
`${accessibleLabel}: ${t(next ? "item.moveResize.keyboard.started" : "item.moveResize.keyboard.stopped")}`,
);
};
const handleKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.target !== event.currentTarget || !isEditMode || event.altKey || event.ctrlKey || event.metaKey) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setKeyboardEditing(!isKeyboardEditing);
return;
}
if (event.key === "Escape" && isKeyboardEditing) {
event.preventDefault();
setKeyboardEditing(false);
return;
}
if (!isKeyboardEditing || !isArrowKey(event.key)) return;
event.preventDefault();
const allPlacements = [...items, ...innerSections].map(toPlacement);
const current = allPlacements.find((candidate) => candidate.id === item.id);
if (!current) return;
const next = getKeyboardPlacement(current, event.key, event.shiftKey, {
columnCount,
minWidth,
minHeight,
maxRowCount,
});
if (next.x === current.x && next.y === current.y && next.w === current.w && next.h === current.h) {
announce(`${accessibleLabel}: ${t("item.moveResize.keyboard.boundary")}`);
return;
}
const resolved = placeGridItem(allPlacements, next, columnCount) as SectionGridPlacement[];
if (maxRowCount !== null && getLayoutRowCount(resolved) > maxRowCount) {
announce(`${accessibleLabel}: ${t("item.moveResize.keyboard.boundary")}`);
return;
}
commitSectionGrid({ layoutId: currentLayoutId, sectionId: section.id, placements: resolved });
announce(
`${displayName}: ${t("item.moveResize.field.xOffset.label")} ${next.x + 1}, ${t(
"item.moveResize.field.yOffset.label",
)} ${next.y + 1}, ${t("item.moveResize.field.width.label")} ${next.w}, ${t(
"item.moveResize.field.height.label",
)} ${next.h}`,
);
};
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
if (isKeyboardEditing && !event.currentTarget.contains(event.relatedTarget)) {
setIsKeyboardEditing(false);
}
};
const semantics = isEditMode
? getEditableGridCellAttributes({ label: accessibleLabel, placement })
: getReadonlyGridItemAttributes(placement);
return (
<Box
{...semantics}
className={combineClasses(isEditMode ? classes.editorEntry : classes.staticItem)}
style={isEditMode ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
data-grid-item-id={isEditMode ? undefined : item.id}
data-grid-item-type={isEditMode ? undefined : item.type}
data-editor-grid-entry={isEditMode ? "true" : undefined}
data-type={item.type}
data-kind={item.kind}
data-keyboard-editing={isEditMode ? String(isKeyboardEditing) : undefined}
aria-describedby={isEditMode ? instructionsId : undefined}
aria-keyshortcuts={
isEditMode
? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown"
: undefined
}
onBlur={isEditMode ? handleBlur : undefined}
onKeyDown={isEditMode ? handleKeyboard : undefined}
>
{children}
{isEditMode && (
<span id={instructionsId} className={classes.liveRegion}>
{t("item.moveResize.keyboard.instructions")}
</span>
)}
</Box>
);
};
const toPlacement = (item: DynamicSectionItem | SectionItem): SectionGridPlacement => ({
id: item.id,
type: item.type,
x: item.xOffset,
y: item.yOffset,
w: item.width,
h: item.height,
});
const isArrowKey = (key: string): key is "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" =>
key === "ArrowLeft" || key === "ArrowRight" || key === "ArrowUp" || key === "ArrowDown";
const getKeyboardPlacement = (
current: SectionGridPlacement,
key: "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown",
resize: boolean,
{
columnCount,
minWidth,
minHeight,
maxRowCount,
}: {
columnCount: number;
minWidth: number;
minHeight: number;
maxRowCount: number | null;
},
): GridPlacement & Pick<SectionGridPlacement, "type"> => {
if (resize) {
const widthDelta = key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0;
const heightDelta = key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0;
return {
...current,
w: Math.max(minWidth, Math.min(columnCount - current.x, current.w + widthDelta)),
h: Math.max(
minHeight,
Math.min(maxRowCount === null ? Number.MAX_SAFE_INTEGER : maxRowCount - current.y, current.h + heightDelta),
),
};
}
const xDelta = key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0;
const yDelta = key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0;
return {
...current,
x: Math.max(0, Math.min(columnCount - current.w, current.x + xDelta)),
y: Math.max(
0,
Math.min(maxRowCount === null ? Number.MAX_SAFE_INTEGER : maxRowCount - current.h, current.y + yDelta),
),
};
};
@@ -0,0 +1,76 @@
"use client";
import type * as GridEditorModule from "./grid-editor";
let editorModulePromise: Promise<typeof GridEditorModule> | undefined;
export const loadGridEditorAsync = () => {
editorModulePromise ??= import("./grid-editor").catch((error: unknown) => {
editorModulePromise = undefined;
throw error;
});
return editorModulePromise;
};
export const scheduleGridEditorWarmup = (loadAsync: () => Promise<unknown>) => {
let animationFrameId: number | undefined;
let idleCallbackId: number | undefined;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let hydrationObserver: MutationObserver | undefined;
let cancelled = false;
const load = () => {
if (!cancelled) void loadAsync().catch(() => undefined);
};
const scheduleAfterPaint = () => {
animationFrameId = window.requestAnimationFrame(() => {
if ("requestIdleCallback" in window) {
idleCallbackId = window.requestIdleCallback(load, { timeout: 2_000 });
} else {
timeoutId = setTimeout(load, 1_000);
}
});
};
const scheduleWhenInteractive = () => {
if (document.querySelector('[data-board-hydrated="true"]')) {
scheduleAfterPaint();
return;
}
hydrationObserver = new MutationObserver(() => {
if (!document.querySelector('[data-board-hydrated="true"]')) return;
hydrationObserver?.disconnect();
hydrationObserver = undefined;
scheduleAfterPaint();
});
hydrationObserver.observe(document.documentElement, {
attributeFilter: ["data-board-hydrated"],
attributes: true,
childList: true,
subtree: true,
});
};
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible") return;
document.removeEventListener("visibilitychange", handleVisibilityChange);
scheduleWhenInteractive();
};
if (document.visibilityState === "visible") {
scheduleWhenInteractive();
} else {
document.addEventListener("visibilitychange", handleVisibilityChange);
}
return () => {
cancelled = true;
document.removeEventListener("visibilitychange", handleVisibilityChange);
hydrationObserver?.disconnect();
if (animationFrameId !== undefined) window.cancelAnimationFrame(animationFrameId);
if (idleCallbackId !== undefined && "cancelIdleCallback" in window) {
window.cancelIdleCallback(idleCallbackId);
}
if (timeoutId !== undefined) clearTimeout(timeoutId);
};
};
@@ -0,0 +1,36 @@
.grid-stack.grid-stack-editing > .grid-stack-placeholder > .placeholder-content {
background: color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
border: 2px dashed var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
}
.grid-stack.grid-stack-editing > .grid-stack-item.ui-draggable-dragging {
filter: drop-shadow(var(--mantine-shadow-lg));
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-handle {
z-index: 8;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w {
width: 28px;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s {
height: 28px;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw {
width: 28px;
height: 28px;
background-size: 12px 12px;
}
@media (prefers-reduced-motion: reduce) {
.grid-stack.grid-stack-editing,
.grid-stack.grid-stack-editing .grid-stack-item {
transition: none !important;
}
}
@@ -0,0 +1,421 @@
"use client";
import type { CSSProperties } from "react";
import { useCallback, useLayoutEffect, useMemo, useRef } from "react";
import "gridstack/dist/gridstack.min.css"; // oxlint-disable-line import/no-unassigned-import
import "./grid-editor.css"; // oxlint-disable-line import/no-unassigned-import
import type { AddRemoveFcn, GridItemHTMLElement, GridStackNode, GridStackWidget } from "gridstack";
import { GridStack } from "gridstack";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { getLogicalTrackSize, LOGICAL_GRID_GAP, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import { useBoardGridPortalHost } from "./grid-portal-host";
import { decorateGridResizeHandles } from "./grid-resize-handles";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions";
const AUTO_EXPAND_ROW_BUFFER = 8;
interface GridEditorProps {
sectionId: string;
columnCount: number;
rowCount: number;
fixedRowCount: boolean;
placements: readonly SectionGridPlacement[];
className: string;
}
/**
* The only board module that imports GridStack. The library owns every
* positioned shell, while one board-level portal host owns React content.
* Moving a shell between independent grids therefore never reparents a
* React-owned element.
*/
export default function GridEditor({
sectionId,
columnCount,
rowCount,
fixedRowCount,
placements,
className,
}: GridEditorProps) {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const t = useI18n();
const { announce, innerSections, items } = useSectionContext();
const { acquireContainer, releaseContainer } = useBoardGridPortalHost();
const { commitSectionGrid, commitSectionGrids } = useGridLayoutActions();
const wrapperRef = useRef<HTMLDivElement>(null);
const gridRef = useRef<GridStack | null>(null);
const applyingControlledLayoutRef = useRef(false);
const placementsRef = useRef(placements);
const entriesRef = useRef([...items, ...innerSections]);
const acceptWidgetRef = useRef<(element: Element) => boolean>(() => false);
const dimensionsRef = useRef({ columnCount, fixedRowCount, rowCount });
placementsRef.current = placements;
entriesRef.current = [...items, ...innerSections];
dimensionsRef.current = { columnCount, fixedRowCount, rowCount };
const forbiddenDynamicSectionIds = useMemo(
() => getTargetAncestorSectionIds(board.sections, currentLayoutId, sectionId),
[board.sections, currentLayoutId, sectionId],
);
acceptWidgetRef.current = (element) => {
const node = (element as GridItemHTMLElement).gridstackNode;
const id = element.getAttribute("data-grid-item-id") ?? node?.id;
if (!id) return false;
if ((node?.minW ?? 1) > columnCount) return false;
if (fixedRowCount && (node?.minH ?? 1) > rowCount) return false;
return element.getAttribute("data-grid-item-type") !== "section" || !forbiddenDynamicSectionIds.has(String(id));
};
const shellCallback = useCallback<AddRemoveFcn>(
(_parent, widget, add, isGrid) => {
if (isGrid) return undefined;
const id = widget.id ? String(widget.id) : null;
if (!id) return undefined;
if (!add) {
const element = (widget as GridStackNode).el;
const container = element?.querySelector<HTMLElement>(":scope > .grid-stack-item-content");
if (container) releaseContainer(id, container);
element?.remove();
return undefined;
}
const element = document.createElement("div") as GridItemHTMLElement;
element.className = "grid-stack-item";
element.appendChild(acquireContainer(id));
return element;
},
[acquireContainer, releaseContainer],
);
const snapshotGrid = useCallback(
(grid: GridStack | undefined) => {
if (!grid?.el) return null;
const snapshotSectionId = grid.el.getAttribute("data-grid-section-id");
if (!snapshotSectionId) return null;
return {
layoutId: currentLayoutId,
sectionId: snapshotSectionId,
placements: getGridPlacements(grid),
};
},
[currentLayoutId],
);
const commitGrid = useCallback(
(grid: GridStack | undefined) => {
if (applyingControlledLayoutRef.current) return;
const snapshot = snapshotGrid(grid);
if (snapshot) commitSectionGrid(snapshot);
},
[commitSectionGrid, snapshotGrid],
);
const announceNode = useCallback(
(node: GridStackNode | undefined) => {
if (!node?.id) return;
const entry = entriesRef.current.find((candidate) => candidate.id === String(node.id));
const displayName =
entry?.type === "item"
? entry.advancedOptions.title?.trim() || t(`widget.${entry.kind}.name`)
: entry?.type === "section"
? entry.options.title.trim() || t("section.dynamic.untitled")
: t("item.action.moveResize");
const label = t("item.moveResize.entryLabel", {
name: displayName,
column: String((node.x ?? 0) + 1),
row: String((node.y ?? 0) + 1),
});
announce(
`${label}: ${t("item.moveResize.field.width.label")} ${node.w ?? 1}, ${t(
"item.moveResize.field.height.label",
)} ${node.h ?? 1}`,
);
},
[announce, t],
);
const eventHandlersRef = useRef({
announceNode,
commitGrid,
commitSectionGrids,
snapshotGrid,
});
eventHandlersRef.current = {
announceNode,
commitGrid,
commitSectionGrids,
snapshotGrid,
};
useLayoutEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const dimensions = dimensionsRef.current;
const grid = GridStack.init(
{
acceptWidgets: (element) => acceptWidgetRef.current(element),
alwaysShowResizeHandle: true,
animate: !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
cellHeight: LOGICAL_GRID_PITCH,
column: dimensions.columnCount,
draggable: {
cancel:
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag],.ui-resizable-handle',
handle: "[data-editor-grid-entry]",
scroll: true,
},
float: true,
margin: `0 ${LOGICAL_GRID_GAP}px ${LOGICAL_GRID_GAP}px 0`,
maxRow: dimensions.fixedRowCount ? dimensions.rowCount : 0,
minRow: dimensions.fixedRowCount ? dimensions.rowCount : 1,
resizable: {
autoHide: false,
handles: "e, se, s, sw, w",
},
},
wrapper,
);
if (!grid) return;
// GridStack infers a native sub-grid from DOM nesting. Homarr owns nested
// grids independently so content can move between them without remounting;
// keeping the inferred link makes parent/child teardown order unsafe.
const inferredParentNode = grid.parentGridNode;
if (inferredParentNode) {
if (inferredParentNode.subGrid === grid) delete inferredParentNode.subGrid;
delete grid.parentGridNode;
wrapper.classList.remove("grid-stack-nested");
inferredParentNode.el?.classList.remove("grid-stack-sub-grid");
}
gridRef.current = grid;
wrapper.setAttribute("data-grid-section-id", sectionId);
wrapper.classList.add("grid-stack-editing");
const handleStop = (_event: Event, element: GridItemHTMLElement) => {
eventHandlersRef.current.commitGrid(element.gridstackNode?.grid ?? grid);
eventHandlersRef.current.announceNode(element.gridstackNode);
decorateGrid(element.gridstackNode?.grid ?? grid, placementsRef.current);
restoreContentHeight(grid);
};
const handleDragStart = () => {
const currentDimensions = dimensionsRef.current;
if (currentDimensions.fixedRowCount) return;
grid.updateOptions({
minRow: currentDimensions.rowCount + AUTO_EXPAND_ROW_BUFFER,
});
setViewportRowCount(grid, currentDimensions.rowCount + AUTO_EXPAND_ROW_BUFFER);
};
const handleDropped = (
_event: Event,
previousNode: GridStackNode | undefined,
newNode: GridStackNode | undefined,
) => {
queueMicrotask(() => {
if (applyingControlledLayoutRef.current) return;
const snapshots = uniqueGrids([previousNode?.grid, newNode?.grid ?? grid])
.map((changedGrid) => eventHandlersRef.current.snapshotGrid(changedGrid))
.filter((snapshot) => snapshot !== null);
if (snapshots.length > 0) eventHandlersRef.current.commitSectionGrids(snapshots);
eventHandlersRef.current.announceNode(newNode);
for (const changedGrid of uniqueGrids([previousNode?.grid, newNode?.grid])) {
decorateGrid(changedGrid, placementsRef.current);
restoreContentHeight(changedGrid);
}
});
};
grid.on("dragstart", handleDragStart);
grid.on("dragstop", handleStop);
grid.on("resizestop", handleStop);
grid.on("dropped", handleDropped);
return () => {
grid.offAll();
const elements = grid.getGridItems();
for (const element of elements) {
const id = element.getAttribute("data-grid-item-id") ?? element.gridstackNode?.id;
const container = element.querySelector<HTMLElement>(":scope > .grid-stack-item-content");
if (id && container) releaseContainer(String(id), container);
}
grid.destroy(false);
for (const element of elements) element.remove();
if (gridRef.current === grid) gridRef.current = null;
};
}, [releaseContainer, sectionId, shellCallback]);
useLayoutEffect(() => {
const grid = gridRef.current;
if (!grid) return;
grid.updateOptions({
column: columnCount,
maxRow: fixedRowCount ? rowCount : 0,
minRow: fixedRowCount ? rowCount : 1,
});
}, [columnCount, fixedRowCount, rowCount]);
useLayoutEffect(() => {
const grid = gridRef.current;
if (!grid) return;
applyingControlledLayoutRef.current = true;
grid.load(placements.map(toGridStackWidget), shellCallback);
applyingControlledLayoutRef.current = false;
decorateGrid(grid, placements);
restoreContentHeight(grid);
const actual = getGridPlacements(grid);
if (!arePlacementsEqual(actual, placements)) {
commitSectionGrid({ layoutId: currentLayoutId, sectionId, placements: actual });
}
}, [commitSectionGrid, currentLayoutId, placements, sectionId, shellCallback]);
return (
<>
<div
ref={wrapperRef}
className={className}
data-grid-section-id={sectionId}
style={
{
width: columnCount * LOGICAL_GRID_PITCH,
minHeight: rowCount * LOGICAL_GRID_PITCH,
} as CSSProperties
}
/>
<span
data-grid-runtime="homarr-edit-grid-v1"
data-editor-section-id={sectionId}
aria-hidden="true"
style={{ position: "absolute", width: 1, height: 1, pointerEvents: "none" }}
/>
</>
);
}
const toGridStackWidget = (placement: SectionGridPlacement): GridStackWidget => ({
id: placement.id,
x: placement.x,
y: placement.y,
w: placement.w,
h: placement.h,
minW: placement.minW ?? 1,
minH: placement.minH ?? 1,
});
const getGridPlacements = (grid: GridStack): SectionGridPlacement[] =>
grid.getGridItems().flatMap((element) => {
const node = element.gridstackNode;
const id = element.getAttribute("data-grid-item-id") ?? node?.id;
const type = element.getAttribute("data-grid-item-type");
if (!node || !id || (type !== "item" && type !== "section")) return [];
return [
{
id: String(id),
type,
x: node.x ?? 0,
y: node.y ?? 0,
w: node.w ?? 1,
h: node.h ?? 1,
minW: node.minW,
minH: node.minH,
},
];
});
const decorateGrid = (grid: GridStack | undefined, placements: readonly SectionGridPlacement[]) => {
if (!grid?.el) return;
const placementById = new Map(placements.map((placement) => [placement.id, placement]));
grid.el.classList.add("grid-stack-editing");
for (const item of grid.getGridItems()) {
const id = item.getAttribute("data-grid-item-id") ?? item.gridstackNode?.id;
const placement = id ? placementById.get(String(id)) : undefined;
decorateItem(item, placement?.type ?? getElementItemType(item));
}
};
const decorateItem = (element: GridItemHTMLElement, type: SectionGridPlacement["type"] | undefined) => {
const node = element.gridstackNode;
if (!node?.id) return;
element.setAttribute("data-grid-item-id", String(node.id));
if (type) element.setAttribute("data-grid-item-type", type);
element.setAttribute("data-grid-x", String(node.x ?? 0));
element.setAttribute("data-grid-y", String(node.y ?? 0));
element.setAttribute("data-grid-w", String(node.w ?? 1));
element.setAttribute("data-grid-h", String(node.h ?? 1));
decorateGridResizeHandles(element);
};
const getElementItemType = (element: Element): SectionGridPlacement["type"] | undefined => {
const type = element.getAttribute("data-grid-item-type");
return type === "item" || type === "section" ? type : undefined;
};
const arePlacementsEqual = (first: readonly SectionGridPlacement[], second: readonly SectionGridPlacement[]) => {
if (first.length !== second.length) return false;
const secondById = new Map(second.map((placement) => [placement.id, placement]));
return first.every((placement) => {
const other = secondById.get(placement.id);
return (
other !== undefined &&
placement.type === other.type &&
placement.x === other.x &&
placement.y === other.y &&
placement.w === other.w &&
placement.h === other.h
);
});
};
const uniqueGrids = (grids: Array<GridStack | undefined>) =>
Array.from(new Set(grids.filter((grid): grid is GridStack => grid !== undefined)));
const restoreContentHeight = (grid: GridStack | undefined) => {
if (!grid || (grid.opts.maxRow ?? 0) > 0) return;
const contentRowCount = Math.max(1, ...getGridPlacements(grid).map((placement) => placement.y + placement.h));
grid.updateOptions({ minRow: contentRowCount });
setViewportRowCount(grid, contentRowCount);
};
const setViewportRowCount = (grid: GridStack, rowCount: number) => {
const viewport = grid.el.parentElement;
if (!viewport?.hasAttribute("data-section-id")) return;
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(rowCount)}px`);
};
const getTargetAncestorSectionIds = (
sections: ReturnType<typeof useRequiredBoard>["sections"],
layoutId: string,
targetSectionId: string,
) => {
const parentById = new Map(
sections
.filter((section) => section.kind === "dynamic")
.flatMap((section) => {
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout ? ([[section.id, layout.parentSectionId]] as const) : [];
}),
);
const ancestors = new Set<string>();
let current: string | undefined = targetSectionId;
while (current && !ancestors.has(current)) {
ancestors.add(current);
current = parentById.get(current);
}
return ancestors;
};
@@ -0,0 +1,194 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { GridItemHTMLElement } from "gridstack";
import type { RouterOutputs } from "@homarr/api";
import { useIntegrations } from "@homarr/auth/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { getRootSectionLane } from "@homarr/definitions";
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount } from "~/components/board/layout";
import { SectionContentItem } from "../content";
import { SectionProvider } from "../section-context";
import { useSectionItems } from "../use-section-items";
import { decorateGridResizeHandles } from "./grid-resize-handles";
import classes from "./section-grid.module.css";
interface GridPortalHostContextValue {
announce: (message: string) => void;
integrations: RouterOutputs["integration"]["all"] | undefined;
acquireContainer: (id: string) => HTMLElement;
releaseContainer: (id: string, container: HTMLElement) => void;
}
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
/**
* One portal registry owns every edit-mode item on the board. Its content
* containers are stable even when GridStack replaces or transfers the outer
* positioning shell, so moving between grids does not remount widget content.
*/
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const integrations = useIntegrations();
const containersRef = useRef<Map<string, HTMLElement>>(new Map());
const [containers, setContainers] = useState<Map<string, HTMLElement>>(() => new Map());
const [announcement, setAnnouncement] = useState({ id: 0, message: "" });
const announce = useCallback((message: string) => {
setAnnouncement((previous) => ({ id: previous.id + 1, message }));
}, []);
const acquireContainer = useCallback((id: string) => {
const existing = containersRef.current.get(id);
if (existing) return existing;
const container = document.createElement("div");
container.className = "grid-stack-item-content";
container.style.overflow = "visible";
containersRef.current.set(id, container);
setContainers(new Map(containersRef.current));
return container;
}, []);
const releaseContainer = useCallback((id: string, container: HTMLElement) => {
// Controlled cross-grid moves remove the source shell before the target
// adopts this container. Wait until all grid effects in the commit ran.
queueMicrotask(() => {
if (container.isConnected || containersRef.current.get(id) !== container) return;
containersRef.current.delete(id);
setContainers(new Map(containersRef.current));
});
}, []);
const value = useMemo<GridPortalHostContextValue>(
() => ({
announce,
integrations,
acquireContainer,
releaseContainer,
}),
[acquireContainer, announce, integrations, releaseContainer],
);
return (
<GridPortalHostContext.Provider value={value}>
{children}
{Array.from(containers, ([id, container]) => {
const ownerSectionId = getOwnerSectionId(board, currentLayoutId, id);
return ownerSectionId
? createPortal(
<GridPortalContent
itemId={id}
ownerSectionId={ownerSectionId}
container={container}
integrations={integrations}
announce={announce}
/>,
container,
id,
)
: null;
})}
<div className={classes.liveRegion} aria-live="polite" aria-atomic="true">
<span key={announcement.id}>{announcement.message}</span>
</div>
</GridPortalHostContext.Provider>
);
};
export const useBoardGridPortalHost = () => {
const context = useContext(GridPortalHostContext);
if (!context) throw new Error("BoardGridPortalHost is required");
return context;
};
interface GridPortalContentProps {
itemId: string;
ownerSectionId: string;
container: HTMLElement;
integrations: RouterOutputs["integration"]["all"] | undefined;
announce: (message: string) => void;
}
const GridPortalContent = ({ itemId, ownerSectionId, container, integrations, announce }: GridPortalContentProps) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { items, innerSections } = useSectionItems(ownerSectionId);
const entry = [...items, ...innerSections].find((candidate) => candidate.id === itemId);
const rawSection = board.sections.find((section) => section.id === ownerSectionId);
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const section = toGridSection(rawSection, currentLayoutId);
useLayoutEffect(() => {
const itemElement = container.parentElement as GridItemHTMLElement | null;
if (!itemElement) return;
itemElement.gridstackNode?.grid?.refreshDragHandles(itemElement);
decorateGridResizeHandles(itemElement);
}, [container, ownerSectionId]);
if (!entry || !section || !currentLayout) return null;
const columnCount =
section.kind === "dynamic"
? section.width
: section.kind === "empty"
? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
: currentLayout.columnCount;
const configuredMaxRowCount = section.kind === "dynamic" ? section.height : null;
const contentRowCount = Math.max(
1,
...items.map((item) => item.yOffset + item.height),
...innerSections.map((innerSection) => innerSection.yOffset + innerSection.height),
);
const maxRowCount = configuredMaxRowCount === null ? null : Math.max(configuredMaxRowCount, contentRowCount);
return (
<SectionProvider
value={{
section,
items,
innerSections,
integrations,
columnCount,
maxRowCount,
announce,
}}
>
<SectionContentItem item={entry} integrations={integrations} />
</SectionProvider>
);
};
const toGridSection = (
section: Section | undefined,
layoutId: string,
): Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem | null => {
if (!section) return null;
if (section.kind !== "dynamic") return section;
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) return null;
return {
...section,
...layout,
type: "section",
};
};
const getOwnerSectionId = (board: ReturnType<typeof useRequiredBoard>, layoutId: string, entryId: string) => {
const item = board.items.find((candidate) => candidate.id === entryId);
if (item) {
return item.layouts.find((layout) => layout.layoutId === layoutId)?.sectionId ?? null;
}
const section = board.sections.find((candidate) => candidate.kind === "dynamic" && candidate.id === entryId);
if (!section || section.kind !== "dynamic") return null;
return section.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId ?? null;
};
@@ -0,0 +1,16 @@
export const decorateGridResizeHandles = (element: Element) => {
for (const handle of element.querySelectorAll<HTMLElement>(".ui-resizable-handle")) {
const direction = Array.from(handle.classList).find((className) =>
/^ui-resizable-(?:n|ne|e|se|s|sw|w|nw)$/.test(className),
);
if (!direction) continue;
const directionName = direction.replace("ui-resizable-", "");
handle.setAttribute(
"data-testid",
directionName === "se" ? "board-grid-resize-handle" : `board-grid-resize-handle-${directionName}`,
);
handle.setAttribute("data-grid-resize-direction", directionName);
handle.setAttribute("aria-hidden", "true");
}
};
@@ -0,0 +1,77 @@
.viewport {
position: relative;
overflow: visible;
}
.staticGrid,
.editorGrid {
position: relative;
overflow: visible;
}
.editorGrid {
position: absolute;
inset: 0 auto auto 0;
}
.editorGrid:not(:global(.grid-stack-editing)) {
visibility: hidden;
}
.staticItem {
overflow: visible;
transition:
left 220ms ease,
top 220ms ease,
width 220ms ease,
height 220ms ease;
}
.editorEntry {
width: 100%;
height: 100%;
overflow: visible;
cursor: grab;
}
.editorEntry:active {
cursor: grabbing;
}
.editorEntry:focus-visible,
.editorEntry[data-keyboard-editing="true"] {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
border-radius: var(--mantine-radius-md);
}
.editorLoading {
position: absolute;
inset: 0;
z-index: 20;
overflow: hidden;
}
.editorLoadingItem {
background-color: var(--mantine-color-default);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
}
.liveRegion {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
.staticItem {
transition: none;
}
}
@@ -0,0 +1,241 @@
"use client";
import { useMemo } from "react";
import dynamic from "next/dynamic";
import { Box, LoadingOverlay } from "@mantine/core";
import combineClasses from "clsx";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useI18n } from "@homarr/translation/client";
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
import {
getCollapsedDisplayLayout,
getEditableCanvasAttributes,
getLayoutRowCount,
getLogicalItemStyle,
getLogicalTrackSize,
getReadonlyCanvasAttributes,
normalizeGridPlacement,
} from "~/components/board/layout";
import { loadGridEditorAsync } from "./grid-editor-loader";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
import { SectionContent } from "../content";
import { useCollapsedSectionIds } from "../section-collapse";
import { SectionProvider, useSectionContext } from "../section-context";
import { useSectionItems } from "../use-section-items";
import { useBoardGridPortalHost } from "./grid-portal-host";
import classes from "./section-grid.module.css";
const GridEditor = dynamic(loadGridEditorAsync, {
loading: GridEditorLoading,
ssr: false,
});
function GridEditorLoading() {
const t = useI18n();
const { items, innerSections } = useSectionContext();
return (
<Box
component="output"
className={classes.editorLoading}
data-testid="board-grid-editor-loading"
aria-label={t("common.action.loading")}
>
{[...items, ...innerSections].map((item) => (
<Box
key={item.id}
className={classes.editorLoadingItem}
style={getLogicalItemStyle({
x: item.xOffset,
y: item.yOffset,
w: item.width,
h: item.height,
})}
aria-hidden="true"
/>
))}
<LoadingOverlay visible />
</Box>
);
}
interface SectionGridProps {
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
columnCount: number;
requestedRowCount?: number;
label: string;
railPlacement?: "main" | "left" | "right";
className?: string;
}
export const SectionGrid = ({
section,
columnCount,
requestedRowCount = 0,
label,
railPlacement = "main",
className,
}: SectionGridProps) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { items, innerSections } = useSectionItems(section.id);
const { announce, integrations } = useBoardGridPortalHost();
const collapsedSectionIds = useCollapsedSectionIds();
const minimumBySectionId = useMemo(
() =>
new Map(
innerSections.map((innerSection) => [
innerSection.id,
getDynamicSectionMinimumSize(board, currentLayoutId, innerSection.id),
]),
),
[board, currentLayoutId, innerSections],
);
const placements = useMemo(
() =>
[...items, ...innerSections].map((item): SectionGridPlacement => {
const minimum = item.type === "section" ? minimumBySectionId.get(item.id) : undefined;
return normalizeGridPlacement(
{
id: item.id,
type: item.type,
x: item.xOffset,
y: item.yOffset,
w: item.width,
h: item.height,
minW: minimum?.width,
minH: minimum?.height,
},
columnCount,
);
}),
[columnCount, innerSections, items, minimumBySectionId],
);
const displayPlacements = useMemo(() => {
const directCollapsedIds = new Set(
placements
.filter(
(placement) =>
placement.type === "section" &&
collapsedSectionIds.has(placement.id) &&
innerSections.find((innerSection) => innerSection.id === placement.id)?.options.collapsible,
)
.map((placement) => placement.id),
);
if (directCollapsedIds.size === 0) return placements;
return getCollapsedDisplayLayout(placements, {
columnCount,
collapsedItemIds: directCollapsedIds,
});
}, [collapsedSectionIds, columnCount, innerSections, placements]);
const placementById = useMemo(
() => new Map(displayPlacements.map((placement) => [placement.id, placement])),
[displayPlacements],
);
const displayedItems = items.map((item) => withPlacement(item, placementById.get(item.id)));
const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id)));
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const rowCount = Math.max(contentRowCount, requestedRowCount);
const fixedRowCount = section.kind === "dynamic";
const logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalTrackSize(rowCount);
const canvasAttributes = isEditMode
? getEditableCanvasAttributes({ label, columnCount, rowCount })
: getReadonlyCanvasAttributes({ label });
return (
<SectionProvider
value={{
section,
items: displayedItems,
innerSections: displayedInnerSections,
integrations,
columnCount,
maxRowCount: fixedRowCount ? rowCount : null,
announce,
}}
>
<Box
{...canvasAttributes}
className={combineClasses(classes.viewport, className)}
w={logicalWidth}
h={`var(--board-grid-drag-height, ${logicalHeight}px)`}
data-section-id={section.id}
data-section-kind={section.kind}
data-rail-placement={railPlacement}
>
{isEditMode ? (
<GridEditor
sectionId={section.id}
columnCount={columnCount}
rowCount={rowCount}
fixedRowCount={fixedRowCount}
placements={displayPlacements}
className={combineClasses("grid-stack", classes.editorGrid)}
/>
) : (
<Box
className={classes.staticGrid}
style={{ width: logicalWidth, height: logicalHeight }}
data-grid-section-id={section.id}
data-kind={section.kind}
>
<SectionContent />
</Box>
)}
</Box>
</SectionProvider>
);
};
const getDynamicSectionMinimumSize = (
board: ReturnType<typeof useRequiredBoard>,
layoutId: string,
sectionId: string,
) => {
const directItems = board.items.flatMap((item) => {
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout?.sectionId === sectionId ? [layout] : [];
});
const directSections = board.sections.flatMap((candidate) => {
if (candidate.kind !== "dynamic") return [];
const layout = candidate.layouts.find((candidateLayout) => candidateLayout.layoutId === layoutId);
return layout?.parentSectionId === sectionId ? [layout] : [];
});
const children = [...directItems, ...directSections];
return {
width: Math.max(1, ...children.map((child) => child.xOffset + child.width)),
height: Math.max(1, ...children.map((child) => child.yOffset + child.height)),
};
};
const withPlacement = <
TItem extends {
id: string;
xOffset: number;
yOffset: number;
width: number;
height: number;
},
>(
item: TItem,
placement: SectionGridPlacement | undefined,
): TItem => {
if (!placement) return item;
return {
...item,
xOffset: placement.x,
yOffset: placement.y,
width: placement.w,
height: placement.h,
};
};
@@ -0,0 +1,254 @@
import { act, createElement } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getCollapsedDisplayLayout, LOGICAL_GRID_CELL_SIZE } from "../../../layout";
import { FixedGridItem } from "../fixed-grid-item";
const mocks = vi.hoisted(() => ({
announce: vi.fn(),
commitSectionGrid: vi.fn(),
editMode: true,
maxRowCount: null as number | null,
items: [
{
id: "weather",
type: "item" as const,
kind: "weather" as const,
xOffset: 0,
yOffset: 0,
width: 1,
height: 1,
advancedOptions: {
title: null as string | null,
},
},
{
id: "clock",
type: "item" as const,
kind: "clock" as const,
xOffset: 1,
yOffset: 0,
width: 1,
height: 1,
advancedOptions: {
title: null as string | null,
},
},
],
}));
vi.mock("@homarr/boards/edit-mode", () => ({
useEditMode: () => [mocks.editMode],
}));
vi.mock("@homarr/boards/context", () => ({
useCurrentLayout: () => "layout",
}));
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string, values?: Record<string, string>) =>
key === "item.moveResize.entryLabel" ? `${values?.name}, column ${values?.column}, row ${values?.row}` : key,
}));
vi.mock("../use-grid-layout-actions", () => ({
useGridLayoutActions: () => ({
commitSectionGrid: mocks.commitSectionGrid,
}),
}));
vi.mock("../../section-context", () => ({
useSectionContext: () => ({
section: { id: "root", kind: "empty", options: {} },
items: mocks.items,
innerSections: [],
columnCount: 3,
maxRowCount: mocks.maxRowCount,
announce: mocks.announce,
}),
}));
vi.mock("@mantine/core", async () => {
const React = await import("react");
return {
Box: ({ children, ...props }: { children?: ReactNode }) => React.createElement("div", props, children),
};
});
describe("fixed grid item behavior", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
mocks.announce.mockReset();
mocks.commitSectionGrid.mockReset();
mocks.editMode = true;
mocks.maxRowCount = null;
getWeatherMock().advancedOptions.title = null;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
test("moves with the keyboard and deterministically pushes a collision down", () => {
renderWeather(root);
const entry = getEditorEntry(container);
startKeyboardEditing(entry);
act(() => entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
expect(mocks.commitSectionGrid).toHaveBeenCalledWith({
layoutId: "layout",
sectionId: "root",
placements: [
{ id: "weather", type: "item", x: 1, y: 0, w: 1, h: 1 },
{ id: "clock", type: "item", x: 1, y: 1, w: 1, h: 1 },
],
});
expect(mocks.announce).toHaveBeenCalledTimes(2);
});
test("uses shift plus an arrow to resize while honoring grid bounds", () => {
renderWeather(root);
const entry = getEditorEntry(container);
startKeyboardEditing(entry);
act(() =>
entry.dispatchEvent(
new KeyboardEvent("keydown", {
key: "ArrowRight",
shiftKey: true,
bubbles: true,
}),
),
);
expect(mocks.commitSectionGrid).toHaveBeenCalledWith({
layoutId: "layout",
sectionId: "root",
placements: [
{ id: "weather", type: "item", x: 0, y: 0, w: 2, h: 1 },
{ id: "clock", type: "item", x: 1, y: 1, w: 1, h: 1 },
],
});
mocks.commitSectionGrid.mockReset();
act(() =>
entry.dispatchEvent(
new KeyboardEvent("keydown", {
key: "ArrowLeft",
shiftKey: true,
bubbles: true,
}),
),
);
expect(mocks.commitSectionGrid).not.toHaveBeenCalled();
expect(mocks.announce).toHaveBeenLastCalledWith(
"widget.weather.name, column 1, row 1: item.moveResize.keyboard.boundary",
);
});
test("uses the whole tile as the focusable move surface without a grab button", () => {
getWeatherMock().advancedOptions.title = " Balcony forecast ";
renderWeather(root);
const entry = getEditorEntry(container);
expect(entry.getAttribute("aria-label")).toBe("Balcony forecast, column 1, row 1");
expect(entry.tabIndex).toBe(0);
expect(entry.getAttribute("aria-describedby")).not.toBeNull();
expect(container.querySelector('[data-testid="board-grid-drag-handle"]')).toBeNull();
});
test("renders a view-only 1x1 card at the fixed 200px logical size", () => {
mocks.editMode = false;
renderWeather(root);
const item = container.querySelector<HTMLElement>('[data-grid-id="weather"]');
expect(item).not.toBeNull();
expect(item?.style.width).toBe(`${LOGICAL_GRID_CELL_SIZE}px`);
expect(item?.style.height).toBe(`${LOGICAL_GRID_CELL_SIZE}px`);
expect(item?.getAttribute("role")).toBeNull();
act(() => item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
expect(mocks.commitSectionGrid).not.toHaveBeenCalled();
});
test("does not move or resize beyond a fixed section row boundary", () => {
mocks.maxRowCount = 1;
renderWeather(root);
const entry = getEditorEntry(container);
startKeyboardEditing(entry);
act(() => entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
act(() => entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", shiftKey: true, bubbles: true })));
expect(mocks.commitSectionGrid).not.toHaveBeenCalled();
expect(mocks.announce).toHaveBeenNthCalledWith(
2,
"widget.weather.name, column 1, row 1: item.moveResize.keyboard.boundary",
);
expect(mocks.announce).toHaveBeenNthCalledWith(
3,
"widget.weather.name, column 1, row 1: item.moveResize.keyboard.boundary",
);
});
test("collapsing an expanded box changes display geometry without mutating persisted placement", () => {
const persisted = [
{ id: "box", type: "section" as const, x: 0, y: 0, w: 2, h: 4 },
{ id: "app", type: "item" as const, x: 0, y: 4, w: 1, h: 1 },
];
const displayed = getCollapsedDisplayLayout(persisted, {
columnCount: 2,
collapsedItemIds: new Set(["box"]),
});
expect(displayed).toEqual([
{ ...persisted[0], h: 1 },
{ ...persisted[1], y: 1 },
]);
expect(persisted[0]?.h).toBe(4);
expect(persisted[1]?.y).toBe(4);
});
});
const renderWeather = (root: Root) => {
act(() => {
root.render(
createElement(
FixedGridItem,
{
item: getWeatherMock() as never,
},
createElement("span", null, "Weather"),
),
);
});
};
const getWeatherMock = () => {
const item = mocks.items[0];
if (!item) throw new Error("Expected the weather item mock to exist");
return item;
};
const getEditorEntry = (container: HTMLElement) => {
const entry = container.querySelector<HTMLDivElement>("[data-editor-grid-entry]");
if (!entry) throw new Error("Expected the fixed grid item to render an editor entry");
return entry;
};
const startKeyboardEditing = (entry: HTMLDivElement) => {
act(() => entry.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })));
};
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { scheduleGridEditorWarmup } from "../grid-editor-loader";
describe("grid editor warm-up", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
document.body.replaceChildren();
});
test("waits for board hydration, a paint, and browser idle time", async () => {
let animationFrameCallback: FrameRequestCallback | undefined;
let idleCallback: IdleRequestCallback | undefined;
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
animationFrameCallback = callback;
return 1;
});
vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => {
idleCallback = callback;
return 2;
});
vi.stubGlobal("cancelIdleCallback", vi.fn());
const loadAsync = vi.fn().mockResolvedValue(undefined);
const cancel = scheduleGridEditorWarmup(loadAsync);
expect(animationFrameCallback).toBeUndefined();
const canvas = document.createElement("section");
canvas.dataset.boardHydrated = "true";
document.body.append(canvas);
await vi.waitFor(() => expect(animationFrameCallback).toBeDefined());
expect(loadAsync).not.toHaveBeenCalled();
animationFrameCallback?.(0);
expect(idleCallback).toBeDefined();
expect(loadAsync).not.toHaveBeenCalled();
idleCallback?.({
didTimeout: false,
timeRemaining: () => 10,
});
expect(loadAsync).toHaveBeenCalledOnce();
cancel();
});
test("does not load after cancellation", () => {
let idleCallback: IdleRequestCallback | undefined;
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
return 1;
});
vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => {
idleCallback = callback;
return 2;
});
vi.stubGlobal("cancelIdleCallback", vi.fn());
document.body.innerHTML = '<section data-board-hydrated="true"></section>';
const loadAsync = vi.fn().mockResolvedValue(undefined);
const cancel = scheduleGridEditorWarmup(loadAsync);
cancel();
idleCallback?.({ didTimeout: false, timeRemaining: () => 10 });
expect(loadAsync).not.toHaveBeenCalled();
});
test("falls back to a timeout when requestIdleCallback is unavailable", async () => {
vi.useFakeTimers();
const requestIdleCallbackDescriptor = Object.getOwnPropertyDescriptor(window, "requestIdleCallback");
Reflect.deleteProperty(window, "requestIdleCallback");
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
return 1;
});
document.body.innerHTML = '<section data-board-hydrated="true"></section>';
const loadAsync = vi.fn().mockResolvedValue(undefined);
const cancel = scheduleGridEditorWarmup(loadAsync);
await vi.advanceTimersByTimeAsync(1_000);
expect(loadAsync).toHaveBeenCalledOnce();
cancel();
vi.useRealTimers();
if (requestIdleCallbackDescriptor) {
Object.defineProperty(window, "requestIdleCallback", requestIdleCallbackDescriptor);
}
});
test("waits until the document becomes visible", () => {
let visibilityState: DocumentVisibilityState = "hidden";
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibilityState);
const animationFrame = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(1);
const loadAsync = vi.fn().mockResolvedValue(undefined);
document.body.innerHTML = '<section data-board-hydrated="true"></section>';
const cancel = scheduleGridEditorWarmup(loadAsync);
expect(animationFrame).not.toHaveBeenCalled();
visibilityState = "visible";
document.dispatchEvent(new Event("visibilitychange"));
expect(animationFrame).toHaveBeenCalledOnce();
cancel();
});
});
@@ -0,0 +1,236 @@
import { readdirSync, readFileSync } from "node:fs";
import { relative, resolve } from "node:path";
import ts from "typescript";
import { describe, expect, test } from "vitest";
const gridDirectory = resolve(import.meta.dirname, "..");
const nextjsSourceDirectory = resolve(gridDirectory, "../../../../");
const sectionGridPath = resolve(gridDirectory, "section-grid.tsx");
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
describe("board grid import boundary", () => {
test("keeps every GridStack runtime import inside the edit-only module", () => {
const runtimeImports = getSourceFiles(nextjsSourceDirectory)
.flatMap(getRuntimeImports)
.filter(({ specifier }) => specifier === "gridstack" || specifier.startsWith("gridstack/"))
.map(({ filePath, kind, specifier }) => ({
file: toSourceRelativePath(filePath),
kind,
specifier,
}))
.toSorted((first, second) =>
`${first.file}:${first.specifier}:${first.kind}`.localeCompare(
`${second.file}:${second.specifier}:${second.kind}`,
),
);
expect(runtimeImports).toEqual([
{
file: "components/board/sections/grid/grid-editor.tsx",
kind: "static",
specifier: "gridstack",
},
{
file: "components/board/sections/grid/grid-editor.tsx",
kind: "static",
specifier: "gridstack/dist/gridstack.min.css",
},
]);
});
test("loads the editor through next/dynamic only in the edit branch", () => {
const sourceFile = parseSourceFile(sectionGridPath);
const dynamicImport = sourceFile.statements.find(
(statement): statement is ts.ImportDeclaration =>
ts.isImportDeclaration(statement) &&
ts.isStringLiteral(statement.moduleSpecifier) &&
statement.moduleSpecifier.text === "next/dynamic",
);
expect(dynamicImport?.importClause?.name?.text).toBe("dynamic");
const gridEditorDeclaration = findNode(
sourceFile,
(node): node is ts.VariableDeclaration =>
ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === "GridEditor",
);
expect(gridEditorDeclaration).toBeDefined();
expect(gridEditorDeclaration?.initializer && ts.isCallExpression(gridEditorDeclaration.initializer)).toBe(true);
const dynamicCall = gridEditorDeclaration?.initializer;
if (!dynamicCall || !ts.isCallExpression(dynamicCall)) {
throw new Error("GridEditor must be initialized with next/dynamic");
}
expect(ts.isIdentifier(dynamicCall.expression) && dynamicCall.expression.text === "dynamic").toBe(true);
expect(
dynamicCall.arguments.some((argument) => ts.isIdentifier(argument) && argument.text === "loadGridEditorAsync"),
).toBe(true);
expect(dynamicCall.arguments.some(hasDisabledSsrOption)).toBe(true);
expect(dynamicCall.arguments.some(hasEditorLoadingOption)).toBe(true);
expect(getRuntimeImports(sectionGridPath).filter(({ specifier }) => specifier === "./grid-editor")).toEqual([]);
expect(
getRuntimeImports(editorLoaderPath)
.filter(({ specifier }) => specifier === "./grid-editor")
.map(({ kind }) => kind),
).toEqual(["dynamic"]);
const usages = findNodes(
sourceFile,
(node): node is ts.JsxSelfClosingElement =>
ts.isJsxSelfClosingElement(node) && ts.isIdentifier(node.tagName) && node.tagName.text === "GridEditor",
);
expect(usages).toHaveLength(1);
const gridEditorUsage = usages[0];
if (!gridEditorUsage) {
throw new Error("GridEditor must be rendered once");
}
const editConditional = findAncestor(gridEditorUsage, ts.isConditionalExpression);
expect(
editConditional !== undefined &&
ts.isIdentifier(editConditional.condition) &&
editConditional.condition.text === "isEditMode" &&
isDescendantOf(gridEditorUsage, editConditional.whenTrue),
).toBe(true);
});
});
interface RuntimeImport {
filePath: string;
kind: "dynamic" | "require" | "static";
specifier: string;
}
const getSourceFiles = (directory: string): string[] =>
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = resolve(directory, entry.name);
if (entry.isDirectory()) return getSourceFiles(path);
return /\.[cm]?[jt]sx?$/.test(entry.name) ? [path] : [];
});
const getRuntimeImports = (filePath: string): RuntimeImport[] => {
const sourceFile = parseSourceFile(filePath);
const imports: RuntimeImport[] = [];
const visit = (node: ts.Node) => {
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && isRuntimeImportDeclaration(node)) {
imports.push({ filePath, kind: "static", specifier: node.moduleSpecifier.text });
} else if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier) &&
isRuntimeExportDeclaration(node)
) {
imports.push({ filePath, kind: "static", specifier: node.moduleSpecifier.text });
} else if (
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === "require"))
) {
const [argument] = node.arguments;
if (node.arguments.length === 1 && argument && ts.isStringLiteral(argument)) {
imports.push({
filePath,
kind: node.expression.kind === ts.SyntaxKind.ImportKeyword ? "dynamic" : "require",
specifier: argument.text,
});
}
} else if (
ts.isImportEqualsDeclaration(node) &&
!node.isTypeOnly &&
ts.isExternalModuleReference(node.moduleReference) &&
node.moduleReference.expression &&
ts.isStringLiteral(node.moduleReference.expression)
) {
imports.push({ filePath, kind: "static", specifier: node.moduleReference.expression.text });
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return imports;
};
const isRuntimeImportDeclaration = (declaration: ts.ImportDeclaration) => {
const clause = declaration.importClause;
if (!clause) return true;
if (clause.isTypeOnly) return false;
if (clause.name) return true;
if (!clause.namedBindings || ts.isNamespaceImport(clause.namedBindings)) return true;
if (clause.namedBindings.elements.length === 0) return true;
return clause.namedBindings.elements.some((element) => !element.isTypeOnly);
};
const isRuntimeExportDeclaration = (declaration: ts.ExportDeclaration) => {
if (declaration.isTypeOnly) return false;
if (!declaration.exportClause || ts.isNamespaceExport(declaration.exportClause)) return true;
if (declaration.exportClause.elements.length === 0) return true;
return declaration.exportClause.elements.some((element) => !element.isTypeOnly);
};
const parseSourceFile = (filePath: string) =>
ts.createSourceFile(filePath, readFileSync(filePath, "utf8"), ts.ScriptTarget.Latest, true, getScriptKind(filePath));
const getScriptKind = (filePath: string) => {
if (filePath.endsWith(".tsx")) return ts.ScriptKind.TSX;
if (filePath.endsWith(".jsx")) return ts.ScriptKind.JSX;
if (filePath.endsWith(".js") || filePath.endsWith(".mjs") || filePath.endsWith(".cjs")) return ts.ScriptKind.JS;
return ts.ScriptKind.TS;
};
const hasDisabledSsrOption = (node: ts.Node) => {
if (!ts.isObjectLiteralExpression(node)) return false;
return node.properties.some(
(property) =>
ts.isPropertyAssignment(property) &&
ts.isIdentifier(property.name) &&
property.name.text === "ssr" &&
property.initializer.kind === ts.SyntaxKind.FalseKeyword,
);
};
const hasEditorLoadingOption = (node: ts.Node) => {
if (!ts.isObjectLiteralExpression(node)) return false;
return node.properties.some(
(property) =>
ts.isPropertyAssignment(property) &&
ts.isIdentifier(property.name) &&
property.name.text === "loading" &&
ts.isIdentifier(property.initializer) &&
property.initializer.text === "GridEditorLoading",
);
};
const findNode = <TNode extends ts.Node>(
root: ts.Node,
predicate: (node: ts.Node) => node is TNode,
): TNode | undefined => findNodes(root, predicate)[0];
const findNodes = <TNode extends ts.Node>(root: ts.Node, predicate: (node: ts.Node) => node is TNode) => {
const matches: TNode[] = [];
const visit = (node: ts.Node) => {
if (predicate(node)) matches.push(node);
ts.forEachChild(node, visit);
};
visit(root);
return matches;
};
const findAncestor = <TNode extends ts.Node>(
node: ts.Node,
predicate: (candidate: ts.Node) => candidate is TNode,
): TNode | undefined => {
let current = node.parent;
while (current) {
if (predicate(current)) return current;
current = current.parent;
}
return undefined;
};
const isDescendantOf = (node: ts.Node, ancestor: ts.Node) => node.pos >= ancestor.pos && node.end <= ancestor.end;
const toSourceRelativePath = (filePath: string) => relative(nextjsSourceDirectory, filePath).replaceAll("\\", "/");
@@ -0,0 +1,24 @@
import { describe, expect, test } from "vitest";
import { decorateGridResizeHandles } from "../grid-resize-handles";
describe("grid resize handle decoration", () => {
test("restores accessible hit-target markers after GridStack refreshes its handles", () => {
const item = document.createElement("div");
item.innerHTML = `
<div class="ui-resizable-handle ui-resizable-e"></div>
<div class="ui-resizable-handle ui-resizable-se"></div>
`;
decorateGridResizeHandles(item);
const east = item.querySelector<HTMLElement>(".ui-resizable-e");
const southeast = item.querySelector<HTMLElement>(".ui-resizable-se");
expect(east?.dataset.testid).toBe("board-grid-resize-handle-e");
expect(east?.dataset.gridResizeDirection).toBe("e");
expect(east?.getAttribute("aria-hidden")).toBe("true");
expect(southeast?.dataset.testid).toBe("board-grid-resize-handle");
expect(southeast?.dataset.gridResizeDirection).toBe("se");
expect(southeast?.getAttribute("aria-hidden")).toBe("true");
});
});
@@ -0,0 +1,92 @@
"use client";
import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { GridPlacement } from "~/components/board/layout";
export interface SectionGridPlacement extends GridPlacement {
type: "item" | "section";
minW?: number;
minH?: number;
}
export interface CommitSectionGridInput {
layoutId: string;
sectionId: string;
placements: readonly SectionGridPlacement[];
}
/**
* Commits a complete section snapshot in one cache update. GridStack can move
* several neighbours for one gesture, so persisting only the active item would
* lose its collision result.
*/
export const useGridLayoutActions = () => {
const { updateBoard } = useUpdateBoard();
const commitSectionGrids = useCallback(
(snapshots: readonly CommitSectionGridInput[]) => {
const byId = new Map(
snapshots.flatMap(({ layoutId, sectionId, placements }) =>
placements.map((placement) => [`${layoutId}:${placement.id}`, { layoutId, sectionId, placement }] as const),
),
);
updateBoard((previous) => {
return {
...previous,
items: previous.items.map((item) => {
const layouts = item.layouts.map((layout) => {
const resolved = byId.get(`${layout.layoutId}:${item.id}`);
if (!resolved || resolved.placement.type !== "item") return layout;
const { placement, sectionId } = resolved;
return {
...layout,
sectionId,
xOffset: placement.x,
yOffset: placement.y,
width: placement.w,
height: placement.h,
};
});
return layouts.some((layout, index) => layout !== item.layouts[index]) ? { ...item, layouts } : item;
}),
sections: previous.sections.map((innerSection) => {
if (innerSection.kind !== "dynamic") return innerSection;
const layouts = innerSection.layouts.map((layout) => {
const resolved = byId.get(`${layout.layoutId}:${innerSection.id}`);
if (!resolved || resolved.placement.type !== "section") return layout;
const { placement, sectionId } = resolved;
return {
...layout,
parentSectionId: sectionId,
xOffset: placement.x,
yOffset: placement.y,
width: placement.w,
height: placement.h,
};
});
return layouts.some((layout, index) => layout !== innerSection.layouts[index])
? { ...innerSection, layouts }
: innerSection;
}),
};
});
},
[updateBoard],
);
const commitSectionGrid = useCallback(
(snapshot: CommitSectionGridInput) => {
commitSectionGrids([snapshot]);
},
[commitSectionGrids],
);
return { commitSectionGrid, commitSectionGrids };
};
@@ -1,62 +0,0 @@
import { useEffect } from "react";
import type { PropsWithChildren } from "react";
import type { BoxProps } from "@mantine/core";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
import type { SectionKind, WidgetKind } from "@homarr/definitions";
import type { GridItemHTMLElement } from "@homarr/gridstack";
interface Props extends BoxProps {
id: string;
type: "item" | "section";
kind: WidgetKind | SectionKind;
xOffset: number;
yOffset: number;
width: number;
height: number;
minWidth?: number;
minHeight?: number;
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
}
export const GridStackItem = ({
id,
type,
kind,
xOffset,
yOffset,
width,
height,
minWidth = 1,
minHeight = 1,
innerRef,
children,
...boxProps
}: PropsWithChildren<Props>) => {
useEffect(() => {
if (!innerRef?.current?.gridstackNode) return;
if (type !== "section") return;
innerRef.current.gridstackNode.minW = minWidth;
innerRef.current.gridstackNode.minH = minHeight;
}, [minWidth, minHeight, innerRef, type]);
return (
<Box
{...boxProps}
className={combineClasses("grid-stack-item", boxProps.className)}
data-id={id}
data-type={type}
data-kind={kind}
gs-x={xOffset}
gs-y={yOffset}
gs-w={width}
gs-h={height}
gs-min-w={minWidth}
gs-min-h={minHeight}
ref={innerRef as React.RefObject<HTMLDivElement>}
>
{children}
</Box>
);
};
@@ -1,36 +0,0 @@
"use client";
import type { BoxProps } from "@mantine/core";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
import { SectionContent } from "../content";
import { SectionProvider } from "../section-context";
import { useSectionItems } from "../use-section-items";
import { useGridstack } from "./use-gridstack";
interface Props extends BoxProps {
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
}
export const GridStack = ({ section, ...props }: Props) => {
const { items, innerSections } = useSectionItems(section.id);
const itemIds = [...items, ...innerSections].map((item) => item.id);
const { refs } = useGridstack(section, itemIds);
return (
<SectionProvider value={{ section, items, innerSections, refs }}>
<Box
{...props}
data-kind={section.kind}
data-section-id={section.id}
className={combineClasses(`grid-stack grid-stack-${section.kind}`, props.className)}
ref={refs.wrapper}
>
<SectionContent />
</Box>
</SectionProvider>
);
};
@@ -1,56 +0,0 @@
import type { RefObject } from "react";
import type { GridItemHTMLElement } from "@homarr/gridstack";
import { GridStack } from "@homarr/gridstack";
import type { Section } from "~/app/[locale]/boards/_types";
interface InitializeGridstackProps {
section: Omit<Section, "items">;
itemIds: string[];
refs: {
wrapper: RefObject<HTMLDivElement | null>;
items: RefObject<Record<string, RefObject<GridItemHTMLElement | null>>>;
gridstack: RefObject<GridStack | null>;
};
sectionColumnCount: number;
}
export const initializeGridstack = ({ section, itemIds, refs, sectionColumnCount }: InitializeGridstackProps) => {
if (!refs.wrapper.current) return false;
// initialize gridstack
const newGrid = refs.gridstack;
newGrid.current = GridStack.init(
{
column: sectionColumnCount,
margin: 10,
cellHeight: 128,
float: true,
alwaysShowResizeHandle: true,
acceptWidgets: true,
staticGrid: true,
minRow: section.kind === "dynamic" && "height" in section ? (section.height as number) : 1,
maxRow: section.kind === "dynamic" && "height" in section ? (section.height as number) : 0,
animate: false,
styleInHead: true,
disableRemoveNodeOnDrop: true,
},
// selector of the gridstack item (it's eather category or wrapper)
`.grid-stack-${section.kind}[data-section-id='${section.id}']`,
);
const grid = newGrid.current;
// Must be used to update the column count after the initialization
grid.column(sectionColumnCount, "none");
grid.batchUpdate();
grid.removeAll(false);
itemIds.forEach((id) => {
const ref = refs.items.current[id]?.current;
if (!ref) return;
grid.makeWidget(ref);
});
grid.batchUpdate(false);
return true;
};
@@ -1,372 +0,0 @@
import type { Ref, RefObject } from "react";
import { createRef, useCallback, useEffect, useRef } from "react";
import { useElementSize, useMergedRef } from "@mantine/hooks";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import type { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode } from "@homarr/gridstack";
import type { Section } from "~/app/[locale]/boards/_types";
import { useMarkSectionAsReady } from "~/app/[locale]/boards/(content)/_ready-context";
import { useItemActions } from "../../items/item-actions";
import { useSectionActions } from "../section-actions";
import { initializeGridstack } from "./init-gridstack";
export interface UseGridstackRefs {
wrapper: Ref<HTMLDivElement | null>;
items: RefObject<Record<string, RefObject<GridItemHTMLElement | null>>>;
gridstack: RefObject<GridStack | null>;
}
interface UseGristackReturnType {
refs: UseGridstackRefs;
}
/**
* When the size of a gridstack changes we need to update the css variables
* so the gridstack items are displayed correctly
* @param wrapper gridstack wrapper
* @param gridstack gridstack object
* @param width width of the section (column count)
* @param height height of the section (row count)
* @param isDynamic if the section is dynamic
*/
const handleResizeChange = (
wrapper: HTMLDivElement,
gridstack: GridStack,
width: number,
height: number,
isDynamic: boolean,
) => {
wrapper.style.setProperty("--gridstack-column-count", width.toString());
wrapper.style.setProperty("--gridstack-row-count", height.toString());
let cellHeight = wrapper.clientWidth / width;
if (isDynamic) {
cellHeight = wrapper.clientHeight / height;
}
if (!isDynamic) {
document.body.style.setProperty("--gridstack-cell-size", cellHeight.toString());
}
gridstack.cellHeight(cellHeight);
};
export const useGridstack = (section: Omit<Section, "items">, itemIds: string[]): UseGristackReturnType => {
const [isEditMode] = useEditMode();
const markAsReady = useMarkSectionAsReady();
const { moveAndResizeItem, moveItemToSection } = useItemActions();
const { moveAndResizeInnerSection, moveInnerSectionToSection } = useSectionActions();
const wrapperElementRef = useRef<HTMLDivElement | null>(null);
const { ref: measureWrapperRef, width, height } = useElementSize<HTMLDivElement>();
const wrapperRef = useMergedRef(measureWrapperRef, wrapperElementRef);
// references to the diffrent items contained in the gridstack
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
// reference of the gridstack object for modifications after initialization
const gridRef = useRef<GridStack>(null);
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId)!;
const columnCount =
section.kind === "dynamic" && "width" in section && typeof section.width === "number"
? section.width
: currentLayout.columnCount;
const itemRefKeys = Object.keys(itemRefs.current);
// define items in itemRefs for easy access and reference to items
if (itemRefKeys.length !== itemIds.length) {
// Remove items that are not in the itemIds
// Otherwise when an item is removed and then another item is added, this foreach below will not run.
itemRefKeys.forEach((id) => {
if (!itemIds.includes(id)) {
delete itemRefs.current[id];
}
});
itemIds.forEach((id) => {
itemRefs.current[id] = itemRefs.current[id] ?? createRef();
});
}
const onChange = useCallback(
(changedNode: GridStackNode) => {
const id = changedNode.el?.getAttribute("data-id");
const type = changedNode.el?.getAttribute("data-type");
if (!id || !type) return;
if (type === "item") {
// Updates the react-query state
moveAndResizeItem({
itemId: id,
// 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
xOffset: changedNode.x!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
yOffset: changedNode.y!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
width: changedNode.w!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
height: changedNode.h!,
});
return;
}
if (type === "section") {
moveAndResizeInnerSection({
innerSectionId: id,
// 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
xOffset: changedNode.x!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
yOffset: changedNode.y!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
width: changedNode.w!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
height: changedNode.h!,
});
return;
}
console.error(`Unknown grid-stack-item type to move. type='${type}' id='${id}'`);
},
[moveAndResizeItem, moveAndResizeInnerSection],
);
const onAdd = useCallback(
(addedNode: GridStackNode) => {
const id = addedNode.el?.getAttribute("data-id");
const type = addedNode.el?.getAttribute("data-type");
if (!id || !type) return;
if (type === "item") {
// Updates the react-query state
moveItemToSection({
itemId: id,
sectionId: section.id,
// 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
xOffset: addedNode.x!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
yOffset: addedNode.y!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
width: addedNode.w!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
height: addedNode.h!,
});
return;
}
if (type === "section") {
moveInnerSectionToSection({
innerSectionId: id,
sectionId: section.id,
// 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
xOffset: addedNode.x!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
yOffset: addedNode.y!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
width: addedNode.w!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
height: addedNode.h!,
});
return;
}
console.error(`Unknown grid-stack-item type to add. type='${type}' id='${id}'`);
},
[moveItemToSection, moveInnerSectionToSection, section.id],
);
// initialize the gridstack
useEffect(() => {
const isReady = initializeGridstack({
section,
itemIds,
refs: {
items: itemRefs,
wrapper: wrapperElementRef,
gridstack: gridRef,
},
sectionColumnCount: columnCount,
});
// If the section is ready mark it as ready
// When all sections are ready the board is ready and will get visible
if (isReady) {
markAsReady(section.id);
}
// Only run this effect when the section items change
}, [itemIds.length, columnCount]);
/**
* IMPORTANT: This effect has to be placed after the effect to initialize the gridstack
* because we need the gridstack object to add the listeners
*/
useEffect(() => {
if (!isEditMode) return;
const currentGrid = gridRef.current;
// Add listener for moving items around in a wrapper
currentGrid?.on("change", (_, nodes) => {
nodes.forEach(onChange);
// For all dynamic section items that changed we want to update the inner gridstack
nodes
.filter((node) => node.el?.getAttribute("data-type") === "section")
.forEach((node) => {
const dynamicInnerGrid = node.el?.querySelector<GridHTMLElement>('.grid-stack[data-kind="dynamic"]');
if (!dynamicInnerGrid?.gridstack) return;
handleResizeChange(
dynamicInnerGrid as HTMLDivElement,
dynamicInnerGrid.gridstack,
node.w ?? 1,
node.h ?? 1,
true,
);
});
});
// Add listener for moving items in config from one wrapper to another
currentGrid?.on("added", (_, nodes) => {
nodes.forEach(onAdd);
});
return () => {
currentGrid?.off("change");
currentGrid?.off("added");
};
}, [isEditMode, onAdd, onChange]);
/**
* IMPORTANT: This effect has to be placed after the effect to initialize the gridstack
* because we need the gridstack object to add the listeners
* Toggle the gridstack to be static or not based on the edit mode
*/
useEffect(() => {
gridRef.current?.setStatic(!isEditMode);
}, [isEditMode]);
const sectionHeight = section.kind === "dynamic" && "height" in section ? (section.height as number) : null;
/**
* IMPORTANT: This effect has to be placed after the effect to initialize the gridstack
* because we need the gridstack object to add the listeners
* We want the amount of rows in a dynamic section to be the height of the section in the outer gridstack
*/
useEffect(() => {
if (!sectionHeight) return;
gridRef.current?.row(sectionHeight);
}, [sectionHeight]);
/**
* IMPORTANT: This effect has to be placed after the effect to initialize the gridstack
* because we need the gridstack object to add the listeners
*/
useCssVariableConfiguration({
columnCount,
gridRef,
wrapperRef: wrapperElementRef,
width,
height,
isDynamic: section.kind === "dynamic",
});
return {
refs: {
items: itemRefs,
wrapper: wrapperRef,
gridstack: gridRef,
},
};
};
interface UseCssVariableConfiguration {
gridRef: UseGridstackRefs["gridstack"];
wrapperRef: RefObject<HTMLDivElement | null>;
width: number;
height: number;
columnCount: number;
isDynamic: boolean;
}
/**
* This hook is used to configure the css variables for the gridstack
* Those css variables are used to define the size of the gridstack items
* @see gridstack.scss
* @param gridRef reference to the gridstack object
* @param wrapperRef reference to the wrapper of the gridstack
* @param width width of the section
* @param height height of the section
* @param columnCount column count of the gridstack
*/
const useCssVariableConfiguration = ({
gridRef,
wrapperRef,
width,
height,
columnCount,
isDynamic,
}: UseCssVariableConfiguration) => {
const onResize = useCallback(() => {
if (!wrapperRef.current) return;
if (!gridRef.current) return;
handleResizeChange(
wrapperRef.current,
gridRef.current,
gridRef.current.getColumn(),
gridRef.current.getRow(),
isDynamic,
);
}, [wrapperRef, gridRef, isDynamic]);
useCallback(() => {
if (!wrapperRef.current) return;
if (!gridRef.current) return;
wrapperRef.current.style.setProperty("--gridstack-column-count", gridRef.current.getColumn().toString());
wrapperRef.current.style.setProperty("--gridstack-row-count", gridRef.current.getRow().toString());
let cellHeight = wrapperRef.current.clientWidth / gridRef.current.getColumn();
if (isDynamic) {
cellHeight = wrapperRef.current.clientHeight / gridRef.current.getRow();
}
gridRef.current.cellHeight(cellHeight);
}, [wrapperRef, gridRef, isDynamic]);
// Define widget-width by calculating the width of one column with mainRef width and column count
useEffect(() => {
onResize();
if (typeof window === "undefined") return;
window.addEventListener("resize", onResize);
const wrapper = wrapperRef.current;
wrapper?.addEventListener("resize", onResize);
return () => {
if (typeof window === "undefined") return;
window.removeEventListener("resize", onResize);
wrapper?.removeEventListener("resize", onResize);
};
}, [wrapperRef, gridRef, onResize]);
// Handle resize of inner sections when there size changes
useEffect(() => {
onResize();
}, [width, height, onResize]);
// Define column count by using the sectionColumnCount
useEffect(() => {
wrapperRef.current?.style.setProperty("--gridstack-column-count", columnCount.toString());
}, [columnCount, wrapperRef]);
};
@@ -0,0 +1,46 @@
interface SectionCollapseStorage {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
}
const legacyStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`;
export const sectionCollapseStorageKey = (sectionId: string) => `homarr-section-collapsed-v2-${sectionId}`;
/**
* Legacy values represented whether the section was open even though the key
* and database column called the value "collapsed". Migrate once into a
* versioned key whose boolean has the corrected meaning.
*/
export const readSectionCollapsedFromStorage = (
storage: SectionCollapseStorage,
sectionId: string,
fallback: boolean,
) => {
const currentKey = sectionCollapseStorageKey(sectionId);
const legacyKey = legacyStorageKey(sectionId);
const current = storage.getItem(currentKey);
if (current !== null) {
storage.removeItem(legacyKey);
return current === "true";
}
const legacy = storage.getItem(legacyKey);
if (legacy === null) return fallback;
const collapsed = legacy !== "true";
storage.setItem(currentKey, String(collapsed));
storage.removeItem(legacyKey);
return collapsed;
};
export const writeSectionCollapsedToStorage = (
storage: SectionCollapseStorage,
sectionId: string,
collapsed: boolean,
) => {
storage.setItem(sectionCollapseStorageKey(sectionId), String(collapsed));
storage.removeItem(legacyStorageKey(sectionId));
};
@@ -0,0 +1,122 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { readSectionCollapsedFromStorage, writeSectionCollapsedToStorage } from "./section-collapse-storage";
interface SectionCollapseContextValue {
collapsedSectionIds: ReadonlySet<string>;
setCollapsed: (sectionId: string, collapsed: boolean) => void;
}
const SectionCollapseContext = createContext<SectionCollapseContextValue | null>(null);
export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
const { status } = useSession();
const { mutate } = clientApi.section.changeCollapsed.useMutation();
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>(() =>
Object.fromEntries(
board.sections.filter((section) => section.kind !== "empty").map((section) => [section.id, section.collapsed]),
),
);
const collapsedByIdRef = useRef(collapsedById);
collapsedByIdRef.current = collapsedById;
useEffect(() => {
setCollapsedById((previous) => {
const next = { ...previous };
for (const section of board.sections) {
if (section.kind === "empty" || section.id in next) continue;
next[section.id] = section.collapsed;
}
return next;
});
}, [board.sections]);
useEffect(() => {
if (status !== "unauthenticated") return;
setCollapsedById((previous) => {
const next = { ...previous };
for (const sectionId of Object.keys(next)) {
next[sectionId] = readSectionCollapsedFromStorage(window.localStorage, sectionId, next[sectionId] ?? false);
}
return next;
});
}, [status]);
const setCollapsed = useCallback(
(sectionId: string, collapsed: boolean) => {
const previousCollapsed = collapsedByIdRef.current[sectionId] ?? false;
collapsedByIdRef.current = { ...collapsedByIdRef.current, [sectionId]: collapsed };
setCollapsedById((previous) => ({ ...previous, [sectionId]: collapsed }));
if (status === "authenticated") {
mutate(
{ sectionId, collapsed },
{
onError: () => {
if (collapsedByIdRef.current[sectionId] !== collapsed) return;
collapsedByIdRef.current = { ...collapsedByIdRef.current, [sectionId]: previousCollapsed };
setCollapsedById((previous) => ({ ...previous, [sectionId]: previousCollapsed }));
},
},
);
} else if (status === "unauthenticated") {
writeSectionCollapsedToStorage(window.localStorage, sectionId, collapsed);
}
},
[mutate, status],
);
const value = useMemo<SectionCollapseContextValue>(
() => ({
collapsedSectionIds: new Set(
Object.entries(collapsedById)
.filter(([, collapsed]) => collapsed)
.map(([sectionId]) => sectionId),
),
setCollapsed,
}),
[collapsedById, setCollapsed],
);
return <SectionCollapseContext.Provider value={value}>{children}</SectionCollapseContext.Provider>;
};
export const useSectionCollapse = ({ sectionId, collapsible }: { sectionId: string; collapsible: boolean }) => {
const context = useContext(SectionCollapseContext);
const [isEditMode] = useEditMode();
if (!context) {
throw new Error("BoardSectionCollapseProvider is required");
}
const isCollapsed = collapsible && context.collapsedSectionIds.has(sectionId);
return {
isCollapsed,
isVisuallyCollapsed: isCollapsed && !isEditMode,
setCollapsed: (collapsed: boolean) => context.setCollapsed(sectionId, collapsed),
toggle: () => context.setCollapsed(sectionId, !isCollapsed),
};
};
export const useCollapsedSectionIds = () => {
const context = useContext(SectionCollapseContext);
const [isEditMode] = useEditMode();
if (!context) {
throw new Error("BoardSectionCollapseProvider is required");
}
return isEditMode ? EMPTY_SET : context.collapsedSectionIds;
};
const EMPTY_SET = new Set<string>();
@@ -1,13 +1,17 @@
import { createContext, useContext } from "react";
import type { RouterOutputs } from "@homarr/api";
import type { DynamicSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
import type { UseGridstackRefs } from "./gridstack/use-gridstack";
interface SectionContextProps {
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
innerSections: DynamicSectionItem[];
items: SectionItem[];
refs: UseGridstackRefs;
integrations: RouterOutputs["integration"]["all"] | undefined;
columnCount: number;
maxRowCount: number | null;
announce: (message: string) => void;
}
const SectionContext = createContext<SectionContextProps | null>(null);
@@ -0,0 +1,53 @@
import { describe, expect, test } from "vitest";
import {
readSectionCollapsedFromStorage,
sectionCollapseStorageKey,
writeSectionCollapsedToStorage,
} from "../section-collapse-storage";
describe("section collapse storage", () => {
test.each([
["true", false],
["false", true],
])("inverts a legacy open value of %s exactly once", (legacyValue, expectedCollapsed) => {
const sectionId = "media";
const legacyKey = `homarr-section-collapsed-${sectionId}`;
const storage = createStorage({ [legacyKey]: legacyValue });
expect(readSectionCollapsedFromStorage(storage, sectionId, false)).toBe(expectedCollapsed);
expect(storage.getItem(legacyKey)).toBeNull();
expect(storage.getItem(sectionCollapseStorageKey(sectionId))).toBe(String(expectedCollapsed));
expect(readSectionCollapsedFromStorage(storage, sectionId, !expectedCollapsed)).toBe(expectedCollapsed);
});
test("uses the corrected fallback when no browser preference exists", () => {
const storage = createStorage();
expect(readSectionCollapsedFromStorage(storage, "category", true)).toBe(true);
expect(readSectionCollapsedFromStorage(storage, "dynamic", false)).toBe(false);
});
test("writes only the versioned corrected state", () => {
const sectionId = "media";
const legacyKey = `homarr-section-collapsed-${sectionId}`;
const storage = createStorage({ [legacyKey]: "true" });
writeSectionCollapsedToStorage(storage, sectionId, true);
expect(storage.getItem(legacyKey)).toBeNull();
expect(storage.getItem(sectionCollapseStorageKey(sectionId))).toBe("true");
});
});
const createStorage = (initial: Record<string, string> = {}) => {
const values = new Map(Object.entries(initial));
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => {
values.delete(key);
},
};
};
@@ -0,0 +1,78 @@
import { describe, expect, test } from "vitest";
import { getSectionItemsForLayout } from "../use-open-section-apps";
describe("getSectionItemsForLayout", () => {
test("includes items in recursively nested dynamic sections", () => {
const board = {
sections: [
{ id: "category", kind: "category" },
{
id: "first",
kind: "dynamic",
layouts: [{ layoutId: "desktop", parentSectionId: "category" }],
},
{
id: "second",
kind: "dynamic",
layouts: [{ layoutId: "desktop", parentSectionId: "first" }],
},
],
items: [
{ id: "direct", layouts: [{ layoutId: "desktop", sectionId: "category" }] },
{ id: "nested", layouts: [{ layoutId: "desktop", sectionId: "second" }] },
],
};
expect(getSectionItemsForLayout(board, "category", "desktop").map((item) => item.id)).toEqual(["direct", "nested"]);
});
test("uses containment from only the requested responsive layout", () => {
const board = {
sections: [
{ id: "category", kind: "category" },
{ id: "other", kind: "category" },
{
id: "dynamic",
kind: "dynamic",
layouts: [
{ layoutId: "mobile", parentSectionId: "category" },
{ layoutId: "desktop", parentSectionId: "other" },
],
},
],
items: [
{
id: "app",
layouts: [
{ layoutId: "mobile", sectionId: "dynamic" },
{ layoutId: "desktop", sectionId: "dynamic" },
],
},
],
};
expect(getSectionItemsForLayout(board, "category", "mobile").map((item) => item.id)).toEqual(["app"]);
expect(getSectionItemsForLayout(board, "category", "desktop")).toEqual([]);
});
test("does not loop when malformed section containment is cyclic", () => {
const board = {
sections: [
{
id: "root",
kind: "dynamic",
layouts: [{ layoutId: "desktop", parentSectionId: "child" }],
},
{
id: "child",
kind: "dynamic",
layouts: [{ layoutId: "desktop", parentSectionId: "root" }],
},
],
items: [{ id: "app", layouts: [{ layoutId: "desktop", sectionId: "child" }] }],
};
expect(getSectionItemsForLayout(board, "root", "desktop").map((item) => item.id)).toEqual(["app"]);
});
});
@@ -0,0 +1,110 @@
import { useCallback, useMemo } from "react";
import { clientApi } from "@homarr/api/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useConfirmModal } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type { Item } from "~/app/[locale]/boards/_types";
import { filterByItemKind } from "./category/filter";
interface SectionItemLayout {
layoutId: string;
sectionId: string;
}
interface SectionLayout {
layoutId: string;
parentSectionId: string;
}
interface SectionTreeItem {
layouts: readonly SectionItemLayout[];
}
interface SectionTreeSection {
id: string;
kind: string;
layouts?: readonly SectionLayout[];
}
interface SectionTree<TItem extends SectionTreeItem> {
items: readonly TItem[];
sections: readonly SectionTreeSection[];
}
/**
* Finds every item contained by a section in one responsive layout. Dynamic
* sections can be nested, so their descendants are traversed recursively.
*/
export const getSectionItemsForLayout = <TItem extends SectionTreeItem>(
board: SectionTree<TItem>,
rootSectionId: string,
layoutId: string,
) => {
const childrenByParent = new Map<string, string[]>();
for (const section of board.sections) {
if (section.kind !== "dynamic") continue;
const layout = section.layouts?.find((candidate) => candidate.layoutId === layoutId);
if (!layout) continue;
const children = childrenByParent.get(layout.parentSectionId) ?? [];
children.push(section.id);
childrenByParent.set(layout.parentSectionId, children);
}
const includedSectionIds = new Set([rootSectionId]);
const queue = [rootSectionId];
for (const sectionId of queue) {
for (const childSectionId of childrenByParent.get(sectionId) ?? []) {
if (includedSectionIds.has(childSectionId)) continue;
includedSectionIds.add(childSectionId);
queue.push(childSectionId);
}
}
return board.items.filter((item) =>
item.layouts.some((layout) => layout.layoutId === layoutId && includedSectionIds.has(layout.sectionId)),
);
};
export const useOpenSectionApps = (sectionId: string, enabled: boolean) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const settings = useSettings();
const { openConfirmModal } = useConfirmModal();
const t = useI18n();
const appIds = useMemo(() => {
const items: Item[] = getSectionItemsForLayout(board, sectionId, currentLayoutId);
return Array.from(new Set(filterByItemKind(items, settings, "app").map((item) => item.options.appId)));
}, [board, currentLayoutId, sectionId, settings]);
const { data: apps = [], isLoading } = clientApi.app.byIds.useQuery(appIds, {
enabled: enabled && appIds.length > 0,
staleTime: 30_000,
});
const open = useCallback(() => {
const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0);
for (const app of appsWithUrls) {
const openedWindow = window.open(app.href ?? undefined);
if (openedWindow) continue;
openConfirmModal({
title: t("section.openAllInNewTabs.title"),
children: t("section.openAllInNewTabs.text"),
});
break;
}
}, [apps, openConfirmModal, t]);
return {
open,
isLoading: enabled && appIds.length > 0 && isLoading,
};
};
@@ -1,4 +1,4 @@
import type { ForwardedRef, ReactNode } from "react";
import type { FocusEventHandler, ForwardedRef, PointerEventHandler, ReactNode } from "react";
import { forwardRef } from "react";
import type { ActionIconProps } from "@mantine/core";
import { ActionIcon } from "@mantine/core";
@@ -8,6 +8,8 @@ import { Link } from "@homarr/ui";
type HeaderButtonProps = (
| {
onClick?: () => void;
onFocus?: FocusEventHandler<HTMLButtonElement>;
onPointerEnter?: PointerEventHandler<HTMLButtonElement>;
}
| {
href: string;
@@ -0,0 +1,28 @@
"use client";
import { useEffect } from "react";
import { boardViewportWidthCookieName } from "@homarr/boards/layout-selection";
export const ViewportHint = () => {
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | undefined;
const persist = () => {
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
document.cookie = `${boardViewportWidthCookieName}=${Math.round(viewportWidth)}; Path=/; Max-Age=31536000; SameSite=Lax`;
};
const handleResize = () => {
clearTimeout(timeout);
timeout = setTimeout(persist, 150);
};
persist();
window.addEventListener("resize", handleResize, { passive: true });
return () => {
clearTimeout(timeout);
window.removeEventListener("resize", handleResize);
};
}, []);
return null;
};