feat(boards): add responsive layout system (#2271)

This commit is contained in:
Meier Lukas
2025-02-23 17:34:56 +01:00
committed by GitHub
parent 2085b5ece2
commit 7761dc29c8
98 changed files with 11770 additions and 1694 deletions

View File

@@ -1,4 +1,6 @@
import type { Board, CategorySection, DynamicSection, EmptySection, Section } from "~/app/[locale]/boards/_types";
import { getBoardLayouts } from "@homarr/boards/context";
import type { Board, CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
export interface RemoveCategoryInput {
id: string;
@@ -28,84 +30,121 @@ export const removeCategoryCallback =
return previous;
}
// Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper
const aboveYOffset = Math.max(
calculateYHeightWithOffsetForItems(aboveSection),
calculateYHeightWithOffsetForDynamicSections(previous.sections, aboveSection.id),
);
const categoryYOffset = Math.max(
calculateYHeightWithOffsetForItems(currentCategory),
calculateYHeightWithOffsetForDynamicSections(previous.sections, currentCategory.id),
);
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 previousCategoryItems = currentCategory.items.map((item) => ({
...item,
yOffset: item.yOffset + aboveYOffset,
}));
const previousBelowWrapperItems = removedSection.items.map((item) => ({
...item,
yOffset: item.yOffset + aboveYOffset + categoryYOffset,
}));
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.yOffset < aboveSection.yOffset && section.kind !== "dynamic"),
{
...aboveSection,
items: [...aboveSection.items, ...previousCategoryItems, ...previousBelowWrapperItems],
},
...previous.sections
.filter(
(section): section is CategorySection | EmptySection =>
section.yOffset > removedSection.yOffset && section.kind !== "dynamic",
)
.map((section) => ({
...section,
position: section.yOffset - 2,
})),
...previous.sections
.filter((section): section is DynamicSection => section.kind === "dynamic")
.map((dynamicSection) => {
// Move dynamic sections from removed section to above section with required yOffset
if (dynamicSection.parentSectionId === removedSection.id) {
return {
...dynamicSection,
yOffset: dynamicSection.yOffset + aboveYOffset + categoryYOffset,
parentSectionId: aboveSection.id,
};
}
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;
// Move dynamic sections from category to above section with required yOffset
if (dynamicSection.parentSectionId === currentCategory.id) {
return {
...dynamicSection,
yOffset: dynamicSection.yOffset + aboveYOffset,
parentSectionId: aboveSection.id,
};
}
if (layout.parentSectionId === currentCategory.id) {
return {
...layout,
yOffset: layout.yOffset + aboveYOffset,
parentSectionId: aboveSection.id,
};
}
return dynamicSection;
}),
],
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 calculateYHeightWithOffsetForDynamicSections = (sections: Section[], sectionId: string) => {
return sections.reduce((acc, section) => {
if (section.kind !== "dynamic" || section.parentSectionId !== sectionId) {
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;
}
const yHeightWithOffset = section.yOffset + section.height;
if (yHeightWithOffset > acc) return yHeightWithOffset;
return acc;
}, 0);
}, 0);
};
const calculateYHeightWithOffsetForItems = (section: Section) =>
section.items.reduce((acc, item) => {
const yHeightWithOffset = item.yOffset + item.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);

View File

@@ -73,5 +73,7 @@ const createSections = (categoryCount: number) => {
};
const sortSections = (sections: Section[]) => {
return sections.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
return sections
.filter((section) => section.kind !== "dynamic")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
};

View File

@@ -1,7 +1,14 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
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", () => {
@@ -13,114 +20,126 @@ describe("Remove Category", () => {
])(
"should remove category",
(removeId, initialYOffsets, expectedYOffsets, expectedRemovals, expectedLocationOfItems) => {
const sections = createSections(initialYOffsets);
// Arrange
const layoutId = "1";
const input = removeId.toString();
const result = removeCategoryCallback({ id: input })({ sections } as never);
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 aboveSection = result.sections.find((section) => section.id === expectedLocationOfItems.toString());
expect(aboveSection?.items.map((item) => parseInt(item.id, 10))).toEqual(
expect.arrayContaining(expectedRemovals),
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 sections: Section[] = createSections(initialYOffsets);
const aboveSection = sections.find((section) => section.yOffset === 2)!;
aboveSection.items = [
createItem({ id: "above-1" }),
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
];
const removedCategory = sections.find((section) => section.yOffset === 3)!;
removedCategory.items = [
createItem({ id: "category-1" }),
createItem({ id: "category-2", yOffset: 2, xOffset: 4, width: 4 }),
];
const removedEmptySection = sections.find((section) => section.yOffset === 4)!;
removedEmptySection.items = [
createItem({ id: "below-1", xOffset: 5 }),
createItem({ id: "below-2", yOffset: 1, xOffset: 1, height: 2 }),
];
sections.push(
createDynamicSection({
id: "7",
parentSectionId: "3",
yOffset: 7,
height: 3,
items: [createItem({ id: "dynamic-1" })],
}),
);
const input = "3";
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();
const result = removeCategoryCallback({ id: input })({ sections } as never);
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 aboveSectionResult = result.sections.find((section) => section.id === "2")!;
expect(aboveSectionResult.items).toEqual(
expect.arrayContaining([
createItem({ id: "above-1" }),
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
createItem({ id: "category-1", yOffset: 5 }),
createItem({ id: "category-2", yOffset: 7, xOffset: 4, width: 4 }),
createItem({ id: "below-1", yOffset: 15, xOffset: 5 }),
createItem({ id: "below-2", yOffset: 16, xOffset: 1, height: 2 }),
]),
);
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.yOffset).toBe(12);
expect(dynamicSection.parentSectionId).toBe("2");
expect(dynamicSection.layouts.at(0)?.yOffset).toBe(12);
expect(dynamicSection.layouts[0]?.parentSectionId).toBe("2");
});
});
const createItem = (item: Partial<{ id: string; width: number; height: number; yOffset: number; xOffset: number }>) => {
return {
id: item.id ?? "0",
kind: "app",
options: {},
advancedOptions: {
customCssClasses: [],
},
height: item.height ?? 1,
width: item.width ?? 1,
yOffset: item.yOffset ?? 0,
xOffset: item.xOffset ?? 0,
integrationIds: [],
} satisfies Item;
};
const createDynamicSection = (
section: Partial<
Pick<DynamicSection, "id" | "height" | "width" | "yOffset" | "xOffset" | "parentSectionId" | "items">
>,
) => {
return {
id: section.id ?? "0",
kind: "dynamic",
height: section.height ?? 1,
width: section.width ?? 1,
yOffset: section.yOffset ?? 0,
xOffset: section.xOffset ?? 0,
parentSectionId: section.parentSectionId ?? "0",
items: section.items ?? [],
} satisfies DynamicSection;
};
const createSections = (initialYOffsets: number[]) => {
return initialYOffsets.map((yOffset, index) => ({
id: yOffset.toString(),
kind: index % 2 === 0 ? "empty" : "category",
name: "Category",
collapsed: false,
yOffset,
xOffset: 0,
items: [createItem({ id: yOffset.toString() })],
})) satisfies Section[];
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(),
);
};

View File

@@ -3,7 +3,7 @@ import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import { createId } from "@homarr/db/client";
import type { CategorySection, EmptySection } from "~/app/[locale]/boards/_types";
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";
@@ -41,14 +41,12 @@ export const useCategoryActions = () => {
yOffset,
xOffset: 0,
collapsed: false,
items: [],
},
{
id: createId(),
kind: "empty",
yOffset: yOffset + 1,
xOffset: 0,
items: [],
},
// Place sections after the new category
...previous.sections
@@ -60,7 +58,7 @@ export const useCategoryActions = () => {
...section,
yOffset: section.yOffset + 2,
})),
],
] satisfies Section[],
}));
},
[updateBoard],
@@ -91,16 +89,14 @@ export const useCategoryActions = () => {
yOffset: lastYOffset + 1,
xOffset: 0,
collapsed: false,
items: [],
},
{
id: createId(),
kind: "empty",
yOffset: lastYOffset + 2,
xOffset: 0,
items: [],
},
],
] satisfies Section[],
};
});
},

View File

@@ -1,6 +1,7 @@
import { useCallback } from "react";
import { fetchApi } from "@homarr/api/client";
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/db/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
@@ -16,6 +17,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const { openConfirmModal } = useConfirmModal();
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
const t = useI18n();
const board = useRequiredBoard();
const createCategoryAtYOffset = useCallback(
(position: number) => {
@@ -102,7 +104,14 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const settings = useSettings();
const openAllInNewTabs = useCallback(async () => {
const appIds = filterByItemKind(category.items, settings, "app").map((item) => {
const currentLayoutId = getCurrentLayout(board);
const appIds = filterByItemKind(
board.items.filter(
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
),
settings,
"app",
).map((item) => {
return item.options.appId;
});
@@ -121,7 +130,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
});
break;
}
}, [category, t, openConfirmModal, settings]);
}, [category, board, t, openConfirmModal, settings]);
return {
addCategoryAbove,

View File

@@ -1,16 +1,16 @@
import { useMemo } from "react";
import { useRequiredBoard } from "@homarr/boards/context";
import type { GridItemHTMLElement } from "@homarr/gridstack";
import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { BoardDynamicSection } from "./dynamic-section";
import { GridStackItem } from "./gridstack/gridstack-item";
import { useSectionContext } from "./section-context";
import { useSectionItems } from "./use-section-items";
export const SectionContent = () => {
const { section, innerSections, refs } = useSectionContext();
const board = useRequiredBoard();
const { innerSections, items, refs } = useSectionContext();
/**
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
@@ -18,41 +18,52 @@ export const SectionContent = () => {
* @see https://github.com/homarr-labs/homarr/pull/1770
*/
const sortedItems = useMemo(() => {
return [
...section.items.map((item) => ({ ...item, type: "item" as const })),
...innerSections.map((section) => ({ ...section, type: "section" as const })),
].sort((itemA, itemB) => {
return [...items, ...innerSections].sort((itemA, itemB) => {
if (itemA.yOffset === itemB.yOffset) {
return itemA.xOffset - itemB.xOffset;
}
return itemA.yOffset - itemB.yOffset;
});
}, [section.items, innerSections]);
}, [items, innerSections]);
return (
<>
{sortedItems.map((item) => (
<GridStackItem
key={item.id}
innerRef={refs.items.current[item.id]}
width={item.width}
height={item.height}
xOffset={item.xOffset}
yOffset={item.yOffset}
kind={item.kind}
id={item.id}
type={item.type}
minWidth={item.type === "section" ? getMinSize("x", item.items, board.sections, item.id) : undefined}
minHeight={item.type === "section" ? getMinSize("y", item.items, board.sections, item.id) : undefined}
>
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
</GridStackItem>
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} />
))}
</>
);
};
interface ItemProps {
item: DynamicSectionItem | SectionItem;
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
}
const Item = ({ item, innerRef }: 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}
>
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
</GridStackItem>
);
};
/**
* 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.
@@ -62,16 +73,13 @@ export const SectionContent = () => {
* @param parentSectionId the id of the section we want to calculate the min size for
* @returns the min size
*/
const getMinSize = (direction: "x" | "y", items: Item[], sections: Section[], parentSectionId: string) => {
const useMinSize = (item: DynamicSectionItem | 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]),
...sections
.filter(
(section): section is DynamicSection =>
section.kind === "dynamic" && section.parentSectionId === parentSectionId,
)
.map((item) => item[`${direction}Offset`] + item[size]),
1, // Minimum size
...innerSections.map((item) => item[`${direction}Offset`] + item[size]),
);
};

View File

@@ -1,18 +1,19 @@
import { Box, Card } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import type { DynamicSection } from "~/app/[locale]/boards/_types";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
import { GridStack } from "./gridstack/gridstack";
import classes from "./item.module.css";
interface Props {
section: DynamicSection;
section: DynamicSectionItem;
}
export const BoardDynamicSection = ({ section }: Props) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
return (
<Box className="grid-stack-item-content">
<Card
@@ -29,7 +30,8 @@ export const BoardDynamicSection = ({ section }: Props) => {
radius={board.itemRadius}
p={0}
>
<GridStack section={section} className="min-row" />
{/* Use unique key by layout to reinitialize gridstack */}
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
</Card>
<BoardDynamicSectionMenu section={section} />
</Box>

View File

@@ -0,0 +1,51 @@
import { getBoardLayouts } from "@homarr/boards/context";
import { createId } from "@homarr/db/client";
import type { Board, DynamicSection, DynamicSectionLayout, EmptySection } from "~/app/[locale]/boards/_types";
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)
.at(0);
if (!firstSection) return board;
const newSection = {
id: createId(),
kind: "dynamic",
layouts: createDynamicSectionLayouts(board, firstSection),
} satisfies DynamicSection;
return {
...board,
sections: board.sections.concat(newSection as unknown as DynamicSection),
};
};
const createDynamicSectionLayouts = (board: Board, currentSection: EmptySection): DynamicSectionLayout[] => {
const layouts = getBoardLayouts(board);
return layouts.map((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
const emptyPosition = boardLayout
? getFirstEmptyPosition(elements, boardLayout.columnCount)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) {
throw new Error("Your board is full");
}
return {
width: 1,
height: 1,
...emptyPosition,
parentSectionId: currentSection.id,
layoutId,
};
});
};

View File

@@ -0,0 +1,62 @@
import type { Board, DynamicSection } from "~/app/[locale]/boards/_types";
export interface RemoveDynamicSectionInput {
id: string;
}
export const removeDynamicSectionCallback =
({ id }: RemoveDynamicSectionInput) =>
(board: Board): Board => {
const sectionToRemove = board.sections.find(
(section): section is DynamicSection => section.id === id && section.kind === "dynamic",
);
if (!sectionToRemove) return board;
return {
...board,
sections: board.sections
.filter((section) => section.id !== id)
.map((section) => {
if (section.kind !== "dynamic") return section;
// Change xOffset and yOffset of sections that were below the removed section and set parentSectionId to the parent of the removed section
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.parentSectionId !== sectionToRemove.id) return layout;
const removedSectionLayout = sectionToRemove.layouts.find(
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
);
if (!removedSectionLayout) throw new Error("Layout not found");
return {
...layout,
xOffset: layout.xOffset + removedSectionLayout.xOffset,
yOffset: layout.yOffset + removedSectionLayout.yOffset,
parentSectionId: removedSectionLayout.parentSectionId,
};
}),
};
}),
// Move all items in dynamic section to parent of the removed section
items: board.items.map((item) => ({
...item,
layouts: item.layouts.map((layout) => {
if (layout.sectionId !== sectionToRemove.id) return layout;
const removedSectionLayout = sectionToRemove.layouts.find(
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
);
if (!removedSectionLayout) throw new Error("Layout not found");
return {
...layout,
xOffset: layout.xOffset + removedSectionLayout.xOffset,
yOffset: layout.yOffset + removedSectionLayout.yOffset,
sectionId: removedSectionLayout.parentSectionId,
};
}),
})),
};
};

View File

@@ -1,83 +1,21 @@
import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import { createId } from "@homarr/db/client";
import type { DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
interface RemoveDynamicSection {
id: string;
}
import { addDynamicSectionCallback } from "./actions/add-dynamic-section";
import type { RemoveDynamicSectionInput } from "./actions/remove-dynamic-section";
import { removeDynamicSectionCallback } from "./actions/remove-dynamic-section";
export const useDynamicSectionActions = () => {
const { updateBoard } = useUpdateBoard();
const addDynamicSection = useCallback(() => {
updateBoard((previous) => {
const lastSection = previous.sections
.filter((section): section is EmptySection => section.kind === "empty")
.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)[0];
if (!lastSection) return previous;
const newSection = {
id: createId(),
kind: "dynamic",
height: 1,
width: 1,
items: [],
parentSectionId: lastSection.id,
// We omit xOffset and yOffset because gridstack will use the first available position
} satisfies Omit<DynamicSection, "xOffset" | "yOffset">;
return {
...previous,
sections: previous.sections.concat(newSection as unknown as DynamicSection),
};
});
updateBoard(addDynamicSectionCallback());
}, [updateBoard]);
const removeDynamicSection = useCallback(
({ id }: RemoveDynamicSection) => {
updateBoard((previous) => {
const sectionToRemove = previous.sections.find(
(section): section is DynamicSection => section.id === id && section.kind === "dynamic",
);
if (!sectionToRemove) return previous;
return {
...previous,
sections: previous.sections
.filter((section) => section.id !== id)
.map((section) => {
if (section.id === sectionToRemove.parentSectionId) {
return {
...section,
// Add items from the removed section to the parent section
items: section.items.concat(
sectionToRemove.items.map((item) => ({
...item,
xOffset: sectionToRemove.xOffset + item.xOffset,
yOffset: sectionToRemove.yOffset + item.yOffset,
})),
),
};
}
if (section.kind === "dynamic" && section.parentSectionId === sectionToRemove.id) {
// Change xOffset and yOffset of sections that were below the removed section and set parentSectionId to the parent of the removed section
return {
...section,
parentSectionId: sectionToRemove.parentSectionId,
yOffset: section.yOffset + sectionToRemove.yOffset,
xOffset: section.xOffset + sectionToRemove.xOffset,
};
}
return section;
}),
};
});
(input: RemoveDynamicSectionInput) => {
updateBoard(removeDynamicSectionCallback(input));
},
[updateBoard],
);

View File

@@ -5,10 +5,10 @@ import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { DynamicSection } from "~/app/[locale]/boards/_types";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import { useDynamicSectionActions } from "./dynamic-actions";
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }) => {
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionItem }) => {
const t = useI18n();
const tDynamic = useScopedI18n("section.dynamic");
const { removeDynamicSection } = useDynamicSectionActions();

View File

@@ -11,14 +11,15 @@ interface Props {
}
export const BoardEmptySection = ({ section }: Props) => {
const { itemIds } = useSectionItems(section);
const { items, innerSections } = useSectionItems(section.id);
const totalLength = items.length + innerSections.length;
const [isEditMode] = useEditMode();
return (
<GridStack
section={section}
style={{ transitionDuration: "0s" }}
className={combineClasses("min-row", itemIds.length > 0 || isEditMode ? undefined : "grid-stack-empty-wrapper")}
className={combineClasses("min-row", totalLength > 0 || isEditMode ? undefined : "grid-stack-empty-wrapper")}
/>
);
};

View File

@@ -4,23 +4,24 @@ import type { BoxProps } from "@mantine/core";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
import type { Section } from "~/app/[locale]/boards/_types";
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: Section;
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
}
export const GridStack = ({ section, ...props }: Props) => {
const { itemIds, innerSections } = useSectionItems(section);
const { items, innerSections } = useSectionItems(section.id);
const itemIds = [...items, ...innerSections].map((item) => item.id);
const { refs } = useGridstack(section, itemIds);
return (
<SectionProvider value={{ section, innerSections, refs }}>
<SectionProvider value={{ section, items, innerSections, refs }}>
<Box
{...props}
data-kind={section.kind}

View File

@@ -2,7 +2,7 @@ import type { RefObject } from "react";
import { createRef, useCallback, useEffect, useRef } from "react";
import { useElementSize } from "@mantine/hooks";
import { useRequiredBoard } from "@homarr/boards/context";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import type { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode } from "@homarr/gridstack";
@@ -68,10 +68,13 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
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
: board.columnCount;
: currentLayout.columnCount;
const itemRefKeys = Object.keys(itemRefs.current);
// define items in itemRefs for easy access and reference to items

View File

@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { getCurrentLayout } from "@homarr/boards/context";
import { useUpdateBoard } from "@homarr/boards/updater";
interface MoveAndResizeInnerSection {
@@ -28,9 +29,19 @@ export const useSectionActions = () => {
sections: previous.sections.map((section) => {
// Return same section if section is not the one we're moving
if (section.id !== innerSectionId) return section;
if (section.kind !== "dynamic") return section;
const currentLayout = getCurrentLayout(previous);
return {
...section,
...positionProps,
layouts: section.layouts.map((layout) => {
if (layout.layoutId !== currentLayout) return layout;
return {
...layout,
...positionProps,
};
}),
};
}),
}));
@@ -46,10 +57,20 @@ export const useSectionActions = () => {
sections: previous.sections.map((section) => {
// Return section without changes when not the section we're moving
if (section.id !== innerSectionId) return section;
if (section.kind !== "dynamic") return section;
const currentLayout = getCurrentLayout(previous);
return {
...section,
...positionProps,
parentSectionId: sectionId,
layouts: section.layouts.map((layout) => {
if (layout.layoutId !== currentLayout) return layout;
return {
...layout,
...positionProps,
parentSectionId: sectionId,
};
}),
};
}),
};

View File

@@ -1,11 +1,12 @@
import { createContext, useContext } from "react";
import type { Section } from "~/app/[locale]/boards/_types";
import type { DynamicSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
import type { UseGridstackRefs } from "./gridstack/use-gridstack";
interface SectionContextProps {
section: Section;
innerSections: Exclude<Section, { kind: "category" } | { kind: "empty" }>[];
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
innerSections: DynamicSectionItem[];
items: SectionItem[];
refs: UseGridstackRefs;
}

View File

@@ -1,16 +1,53 @@
import { useRequiredBoard } from "@homarr/boards/context";
import { useMemo } from "react";
import type { Section } from "~/app/[locale]/boards/_types";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
export const useSectionItems = (section: Section) => {
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
export const useSectionItems = (sectionId: string): { innerSections: DynamicSectionItem[]; items: SectionItem[] } => {
const board = useRequiredBoard();
const innerSections = board.sections.filter(
(innerSection): innerSection is Exclude<Section, { kind: "category" } | { kind: "empty" }> =>
innerSection.kind === "dynamic" && innerSection.parentSectionId === section.id,
const currentLayoutId = useCurrentLayout();
const innerSections = useMemo(
() =>
board.sections
.filter((innerSection) => innerSection.kind === "dynamic")
.map(({ layouts, ...innerSection }) => {
const layout = layouts.find((layout) => layout.layoutId === currentLayoutId);
if (!layout) return null;
return {
...layout,
...innerSection,
type: "section" as const,
};
})
.filter((item) => item !== null)
.filter((innerSection) => innerSection.parentSectionId === sectionId),
[board.sections, currentLayoutId, sectionId],
);
const items = useMemo(
() =>
board.items
.map(({ layouts, ...item }) => {
const layout = layouts.find((layout) => layout.layoutId === currentLayoutId);
if (!layout) return null;
return {
...layout,
...item,
type: "item" as const,
};
})
.filter((item) => item !== null)
.filter((item) => item.sectionId === sectionId),
[board.items, currentLayoutId, sectionId],
);
return {
innerSections,
itemIds: section.items.map((item) => item.id).concat(innerSections.map((section) => section.id)),
items,
};
};