feat: rebuild board grid experience
This commit is contained in:
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);
|
||||
};
|
||||
-145
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user