feat: rebuild board grid with dnd-kit

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:52:59 +02:00
1 parent 5bde4d3cc5
commit 1f5311e93b
107 files changed
+5628 -3209

No files matched your search

@@ -76,11 +76,8 @@ const getEmptySectionPosition = (
if (!boardLayout) return;
const sectionElements = getSectionElements(board, { sectionId: section.id, layoutId: layout.layoutId });
if (section.kind !== "dynamic") {
const columnCount =
section.kind === "empty"
? getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset))
: boardLayout.columnCount;
if (section.kind === "empty") {
const columnCount = getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset));
const size = {
width: layout.width,
@@ -1,8 +1,8 @@
import type { Board } from "~/app/[locale]/boards/_types";
export const getSectionElements = (board: Board, { sectionId, layoutId }: { sectionId: string; layoutId: string }) => {
const dynamicSectionsOfFirstSection = board.sections
.filter((section) => section.kind === "dynamic")
const containersOfSection = board.sections
.filter((section) => section.kind === "container")
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.map(({ layouts, ...section }) => ({ ...section, ...layouts.find((layout) => layout.layoutId === layoutId)! }))
.filter((section) => section.parentSectionId === sectionId);
@@ -14,5 +14,5 @@ export const getSectionElements = (board: Board, { sectionId, layoutId }: { sect
}))
.filter((item) => item.sectionId === sectionId);
return [...items, ...dynamicSectionsOfFirstSection];
return [...items, ...containersOfSection];
};
@@ -5,7 +5,7 @@ import * as boardContext from "@homarr/boards/context";
import { createItemCallback } from "../create-item";
import * as emptyPositionModule from "../empty-position";
import { BoardMockBuilder } from "./mocks/board-mock";
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
import { ContainerSectionMockBuilder } from "./mocks/container-section-mock";
import { ItemMockBuilder } from "./mocks/item-mock";
import { LayoutMockBuilder } from "./mocks/layout-mock";
@@ -58,7 +58,7 @@ describe("item actions create-item", () => {
);
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
});
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
test("should correctly pass containers and items to getFirstEmptyPosition", () => {
// Arrange
const itemKind = "clock";
const emptyPosition = { xOffset: 5, yOffset: 5 };
@@ -67,21 +67,21 @@ describe("item actions create-item", () => {
const itemAndSectionPosition = { height: 2, width: 3, yOffset: 2, xOffset: 1 };
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
const dynamicSectionInFirstSection = new DynamicSectionMockBuilder({ id: "4" })
const containerInFirstSection = new ContainerSectionMockBuilder({ id: "4" })
.addLayout({ ...itemAndSectionPosition, layoutId, parentSectionId: firstSectionId })
.build();
const itemInFirstSection = new ItemMockBuilder({ id: "12" })
.addLayout({ ...itemAndSectionPosition, layoutId, sectionId: firstSectionId })
.build();
const otherDynamicSection = new DynamicSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
const otherContainer = new ContainerSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
const otherItem = new ItemMockBuilder({ id: "13" }).addLayout({ layoutId }).build();
const board = new BoardMockBuilder()
.addLayout(layout)
.addEmptySection({ id: "1", yOffset: 2 })
.addEmptySection({ id: firstSectionId, yOffset: 0 })
.addEmptySection({ id: "3", yOffset: 1 })
.addSection(dynamicSectionInFirstSection)
.addSection(otherDynamicSection)
.addSection(containerInFirstSection)
.addSection(otherContainer)
.addItem(itemInFirstSection)
.addItem(otherItem)
.build();
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import { getMoveTargets } from "../../item-move-modal";
import { BoardMockBuilder } from "./mocks/board-mock";
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
import { ContainerSectionMockBuilder } from "./mocks/container-section-mock";
import { EmptySectionMockBuilder } from "./mocks/empty-section-mock";
import { ItemMockBuilder } from "./mocks/item-mock";
@@ -20,8 +20,8 @@ describe("item move targets", () => {
new EmptySectionMockBuilder({ id: "canvas", xOffset: 0 }).build(),
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
new EmptySectionMockBuilder({ id: "right-gutter", xOffset: 1 }).build(),
new DynamicSectionMockBuilder({
id: "dynamic",
new ContainerSectionMockBuilder({
id: "container",
options: {
title: "",
customCssClasses: [],
@@ -53,7 +53,7 @@ describe("item move targets", () => {
},
{
canvas: "Dashboard canvas",
dynamicSection: "New section",
container: "New section",
leftRail: "Left dashboard rail",
rightRail: "Right dashboard rail",
numbered: (name, index) => `${name}, destination ${index}`,
@@ -78,7 +78,7 @@ describe("item move targets", () => {
layout.columnCount = 5;
layout.leftGutterColumnCount = 2;
const section = new DynamicSectionMockBuilder({ id: "source" })
const section = new ContainerSectionMockBuilder({ id: "source" })
.addLayout({
layoutId: layout.id,
parentSectionId: "canvas",
@@ -110,7 +110,7 @@ describe("item move targets", () => {
{ ...sourceLayout, id: section.id, type: "section" },
{
canvas: "Canvas",
dynamicSection: "Section",
container: "Section",
leftRail: "Left",
rightRail: "Right",
numbered: (name, index) => `${name} ${index}`,
@@ -1,7 +1,7 @@
import { createId } from "@homarr/common";
import type { Board, DynamicSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
import { DynamicSectionMockBuilder } from "./dynamic-section-mock";
import type { Board, ContainerSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
import { ContainerSectionMockBuilder } from "./container-section-mock";
import { EmptySectionMockBuilder } from "./empty-section-mock";
import { ItemMockBuilder } from "./item-mock";
import { LayoutMockBuilder } from "./layout-mock";
@@ -58,8 +58,8 @@ export class BoardMockBuilder {
return this.addSection(new EmptySectionMockBuilder(emptySection).build());
}
public addDynamicSection(dynamicSection?: Partial<DynamicSection>): BoardMockBuilder {
return this.addSection(new DynamicSectionMockBuilder(dynamicSection).build());
public addContainer(container?: Partial<ContainerSection>): BoardMockBuilder {
return this.addSection(new ContainerSectionMockBuilder(container).build());
}
public addSection(section: Section): BoardMockBuilder {
@@ -0,0 +1,38 @@
import { createId } from "@homarr/common";
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
import type { ContainerSection } from "~/app/[locale]/boards/_types";
type ContainerSectionMock = Omit<Partial<ContainerSection>, "options"> & {
options?: Partial<ContainerSection["options"]>;
};
export class ContainerSectionMockBuilder {
private readonly section: ContainerSection;
constructor(section?: ContainerSectionMock) {
this.section = {
id: section?.id ?? createId(),
kind: "container",
collapsed: section?.collapsed ?? false,
layouts: section?.layouts ?? [],
options: containerSectionOptionsSchema.parse(section?.options),
};
}
public addLayout(layout?: Partial<ContainerSection["layouts"][0]>): ContainerSectionMockBuilder {
this.section.layouts.push({
layoutId: layout?.layoutId ?? createId(),
parentSectionId: layout?.parentSectionId ?? createId(),
xOffset: layout?.xOffset ?? 0,
yOffset: layout?.yOffset ?? 0,
width: layout?.width ?? 1,
height: layout?.height ?? 1,
});
return this;
}
public build(): ContainerSection {
return structuredClone(this.section);
}
}
@@ -1,40 +0,0 @@
import { createId } from "@homarr/common";
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
import type { DynamicSection } from "~/app/[locale]/boards/_types";
type DynamicSectionMock = Omit<Partial<DynamicSection>, "options"> & {
options?: Partial<DynamicSection["options"]>;
};
export class DynamicSectionMockBuilder {
private readonly section: DynamicSection;
constructor(section?: DynamicSectionMock) {
this.section = {
id: createId(),
kind: "dynamic",
layouts: [],
...section,
collapsed: section?.collapsed ?? false,
options: dynamicSectionOptionsSchema.parse(section?.options),
} satisfies DynamicSection;
}
public addLayout(layout?: Partial<DynamicSection["layouts"][0]>): DynamicSectionMockBuilder {
this.section.layouts.push({
layoutId: "1",
height: 1,
width: 1,
xOffset: 0,
yOffset: 0,
parentSectionId: "0",
...layout,
} satisfies DynamicSection["layouts"][0]);
return this;
}
public build(): DynamicSection {
return this.section;
}
}
@@ -13,10 +13,8 @@
transform 150ms ease;
}
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover)
.wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within)
.wrapper {
:global([data-type="item"]:not([data-kind="app"]):hover) .wrapper,
:global([data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
opacity: 1;
transform: translateY(0);
}