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,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,
};
};