feat(boards): add responsive layout system (#2271)
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import { createId } from "@homarr/db/client";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import type { Board, DynamicSection, EmptySection, Item } from "~/app/[locale]/boards/_types";
|
||||
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
|
||||
import { getFirstEmptyPosition } from "./empty-position";
|
||||
import { getSectionElements } from "./section-elements";
|
||||
|
||||
export interface CreateItemInput {
|
||||
kind: WidgetKind;
|
||||
@@ -19,24 +21,11 @@ export const createItemCallback =
|
||||
|
||||
if (!firstSection) return previous;
|
||||
|
||||
const dynamicSectionsOfFirstSection = previous.sections.filter(
|
||||
(section): section is DynamicSection => section.kind === "dynamic" && section.parentSectionId === firstSection.id,
|
||||
);
|
||||
const elements = [...firstSection.items, ...dynamicSectionsOfFirstSection];
|
||||
const emptyPosition = getFirstEmptyPosition(elements, previous.columnCount);
|
||||
|
||||
if (!emptyPosition) {
|
||||
console.error("Your board is full");
|
||||
return previous;
|
||||
}
|
||||
|
||||
const widget = {
|
||||
id: createId(),
|
||||
kind,
|
||||
options: {},
|
||||
width: 1,
|
||||
height: 1,
|
||||
...emptyPosition,
|
||||
layouts: createItemLayouts(previous, firstSection),
|
||||
integrationIds: [],
|
||||
advancedOptions: {
|
||||
customCssClasses: [],
|
||||
@@ -50,13 +39,31 @@ export const createItemCallback =
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (section.id !== firstSection.id) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.concat(widget),
|
||||
};
|
||||
}),
|
||||
items: previous.items.concat(widget),
|
||||
};
|
||||
};
|
||||
|
||||
const createItemLayouts = (board: Board, currentSection: EmptySection): ItemLayout[] => {
|
||||
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,
|
||||
sectionId: currentSection.id,
|
||||
layoutId,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createId } from "@homarr/db/client";
|
||||
|
||||
import type { Board, DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import type { Board, EmptySection, ItemLayout, Section } from "~/app/[locale]/boards/_types";
|
||||
import { getFirstEmptyPosition } from "./empty-position";
|
||||
import { getSectionElements } from "./section-elements";
|
||||
|
||||
export interface DuplicateItemInput {
|
||||
itemId: string;
|
||||
@@ -10,72 +11,78 @@ export interface DuplicateItemInput {
|
||||
export const duplicateItemCallback =
|
||||
({ itemId }: DuplicateItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const itemToDuplicate = previous.sections
|
||||
.flatMap((section) => section.items.map((item) => ({ ...item, sectionId: section.id })))
|
||||
.find((item) => item.id === itemId);
|
||||
const itemToDuplicate = previous.items.find((item) => item.id === itemId);
|
||||
if (!itemToDuplicate) return previous;
|
||||
|
||||
const currentSection = previous.sections.find((section) => section.id === itemToDuplicate.sectionId);
|
||||
if (!currentSection) return previous;
|
||||
const clonedItem = structuredClone(itemToDuplicate);
|
||||
|
||||
const dynamicSectionsOfCurrentSection = previous.sections.filter(
|
||||
(section): section is DynamicSection =>
|
||||
section.kind === "dynamic" && section.parentSectionId === currentSection.id,
|
||||
);
|
||||
const elements = [...currentSection.items, ...dynamicSectionsOfCurrentSection];
|
||||
let sectionId = currentSection.id;
|
||||
let emptyPosition = getFirstEmptyPosition(
|
||||
elements,
|
||||
currentSection.kind === "dynamic" ? currentSection.width : previous.columnCount,
|
||||
currentSection.kind === "dynamic" ? currentSection.height : undefined,
|
||||
{
|
||||
width: itemToDuplicate.width,
|
||||
height: itemToDuplicate.height,
|
||||
},
|
||||
);
|
||||
|
||||
if (!emptyPosition) {
|
||||
const firstSection = previous.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) return previous;
|
||||
|
||||
const dynamicSectionsOfFirstSection = previous.sections.filter(
|
||||
(section): section is DynamicSection =>
|
||||
section.kind === "dynamic" && section.parentSectionId === firstSection.id,
|
||||
);
|
||||
const elements = [...firstSection.items, ...dynamicSectionsOfFirstSection];
|
||||
emptyPosition = getFirstEmptyPosition(elements, previous.columnCount, undefined, {
|
||||
width: itemToDuplicate.width,
|
||||
height: itemToDuplicate.height,
|
||||
});
|
||||
if (!emptyPosition) {
|
||||
console.error("Your board is full");
|
||||
return previous;
|
||||
}
|
||||
|
||||
sectionId = firstSection.id;
|
||||
}
|
||||
|
||||
const widget = structuredClone(itemToDuplicate);
|
||||
widget.id = createId();
|
||||
widget.xOffset = emptyPosition.xOffset;
|
||||
widget.yOffset = emptyPosition.yOffset;
|
||||
widget.sectionId = sectionId;
|
||||
|
||||
const result = {
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (section.id !== sectionId) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.concat(widget),
|
||||
};
|
||||
items: previous.items.concat({
|
||||
...clonedItem,
|
||||
id: createId(),
|
||||
layouts: clonedItem.layouts.map((layout) => ({
|
||||
...layout,
|
||||
...getNextPosition(previous, layout),
|
||||
})),
|
||||
}),
|
||||
};
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const getNextPosition = (board: Board, layout: ItemLayout): { xOffset: number; yOffset: number; sectionId: string } => {
|
||||
const currentSection = board.sections.find((section) => section.id === layout.sectionId);
|
||||
if (currentSection) {
|
||||
const emptySectionPosition = getEmptySectionPosition(board, layout, currentSection);
|
||||
if (emptySectionPosition) {
|
||||
return {
|
||||
...emptySectionPosition,
|
||||
sectionId: currentSection.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const firstSection = board.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) {
|
||||
throw new Error("Your board is full. reason='no first section'");
|
||||
}
|
||||
|
||||
const emptySectionPosition = getEmptySectionPosition(board, layout, firstSection);
|
||||
|
||||
if (!emptySectionPosition) {
|
||||
throw new Error("Your board is full. reason='no empty positions'");
|
||||
}
|
||||
|
||||
return {
|
||||
...emptySectionPosition,
|
||||
sectionId: firstSection.id,
|
||||
};
|
||||
};
|
||||
|
||||
const getEmptySectionPosition = (
|
||||
board: Board,
|
||||
layout: ItemLayout,
|
||||
section: Section,
|
||||
): { xOffset: number; yOffset: number } | undefined => {
|
||||
const boardLayout = board.layouts.find((boardLayout) => boardLayout.id === layout.layoutId);
|
||||
if (!boardLayout) return;
|
||||
|
||||
const sectionElements = getSectionElements(board, { sectionId: layout.sectionId, layoutId: layout.layoutId });
|
||||
if (section.kind !== "dynamic") {
|
||||
return getFirstEmptyPosition(sectionElements, boardLayout.columnCount, undefined, {
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
});
|
||||
}
|
||||
|
||||
const sectionLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layout.layoutId);
|
||||
if (!sectionLayout) return;
|
||||
|
||||
return getFirstEmptyPosition(sectionElements, sectionLayout.width, sectionLayout.height, {
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const getFirstEmptyPosition = (
|
||||
elements: Pick<Item, "yOffset" | "xOffset" | "width" | "height">[],
|
||||
elements: Pick<SectionItem, "yOffset" | "xOffset" | "width" | "height">[],
|
||||
columnCount: number,
|
||||
rowCount = 9999,
|
||||
size: { width: number; height: number } = { width: 1, height: 1 },
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getCurrentLayout } from "@homarr/boards/context";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface MoveAndResizeItemInput {
|
||||
itemId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const moveAndResizeItemCallback =
|
||||
({ itemId, ...layoutInput }: MoveAndResizeItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.map((item) =>
|
||||
item.id !== itemId
|
||||
? item
|
||||
: {
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) =>
|
||||
layout.layoutId !== currentLayout
|
||||
? layout
|
||||
: {
|
||||
...layout,
|
||||
...layoutInput,
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getCurrentLayout } from "@homarr/boards/context";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface MoveItemToSectionInput {
|
||||
itemId: string;
|
||||
sectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const moveItemToSectionCallback =
|
||||
({ itemId, ...layoutInput }: MoveItemToSectionInput) =>
|
||||
(board: Board): Board => {
|
||||
const currentLayout = getCurrentLayout(board);
|
||||
|
||||
return {
|
||||
...board,
|
||||
items: board.items.map((item) =>
|
||||
item.id !== itemId
|
||||
? item
|
||||
: {
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) =>
|
||||
layout.layoutId !== currentLayout
|
||||
? layout
|
||||
: {
|
||||
...layout,
|
||||
...layoutInput,
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface RemoveItemInput {
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
export const removeItemCallback =
|
||||
({ itemId }: RemoveItemInput) =>
|
||||
(board: Board): Board => ({
|
||||
...board,
|
||||
items: board.items.filter((item) => item.id !== itemId),
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
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")
|
||||
// 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);
|
||||
const items = board.items
|
||||
.map(({ layouts, ...item }) => ({
|
||||
...item,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
...layouts.find((layout) => layout.layoutId === layoutId)!,
|
||||
}))
|
||||
.filter((item) => item.sectionId === sectionId);
|
||||
|
||||
return [...items, ...dynamicSectionsOfFirstSection];
|
||||
};
|
||||
@@ -1,61 +1,109 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import { createItemCallback } from "../create-item";
|
||||
import * as emptyPosition from "../empty-position";
|
||||
import { createDynamicSection, createEmptySection, createItem } from "./shared";
|
||||
import * as emptyPositionModule from "../empty-position";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
|
||||
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||
|
||||
describe("item actions create-item", () => {
|
||||
test("should add it to first section", () => {
|
||||
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
|
||||
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
|
||||
const input = {
|
||||
sections: [createEmptySection("1", 2), createEmptySection("2", 0), createEmptySection("3", 1)],
|
||||
columnCount: 4,
|
||||
} satisfies Pick<Board, "sections" | "columnCount">;
|
||||
// Arrange
|
||||
const itemKind = "clock";
|
||||
const emptyPosition = { xOffset: 5, yOffset: 5 };
|
||||
const firstSectionId = "2";
|
||||
const layoutId = "1";
|
||||
|
||||
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout(layout)
|
||||
.addLayout()
|
||||
.addEmptySection({ id: "1", yOffset: 2 })
|
||||
.addEmptySection({ id: firstSectionId, yOffset: 0 })
|
||||
.addEmptySection({ id: "3", yOffset: 1 })
|
||||
.build();
|
||||
|
||||
const emptyPositionSpy = vi.spyOn(emptyPositionModule, "getFirstEmptyPosition");
|
||||
emptyPositionSpy.mockReturnValue(emptyPosition);
|
||||
const layoutsSpy = vi.spyOn(boardContext, "getBoardLayouts");
|
||||
layoutsSpy.mockReturnValue([layoutId]);
|
||||
|
||||
// Act
|
||||
const result = createItemCallback({
|
||||
kind: "clock",
|
||||
})(input as unknown as Board);
|
||||
kind: itemKind,
|
||||
})(board);
|
||||
|
||||
const firstSection = result.sections.find((section) => section.id === "2");
|
||||
const item = firstSection?.items.at(0);
|
||||
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
|
||||
expect(spy).toHaveBeenCalledWith([], input.columnCount);
|
||||
// Assert
|
||||
const item = result.items.at(0);
|
||||
expect(item).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: itemKind,
|
||||
layouts: [
|
||||
{
|
||||
layoutId,
|
||||
height: 1,
|
||||
width: 1,
|
||||
...emptyPosition,
|
||||
sectionId: firstSectionId,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount);
|
||||
});
|
||||
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
|
||||
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
|
||||
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
|
||||
const firstSection = createEmptySection("2", 0);
|
||||
const expectedItem = createItem({ id: "12", xOffset: 1, yOffset: 2, width: 3, height: 2 });
|
||||
firstSection.items.push(expectedItem);
|
||||
const dynamicSectionInFirst = createDynamicSection({
|
||||
id: "4",
|
||||
parentSectionId: "2",
|
||||
yOffset: 0,
|
||||
xOffset: 0,
|
||||
width: 2,
|
||||
height: 2,
|
||||
});
|
||||
// Arrange
|
||||
const itemKind = "clock";
|
||||
const emptyPosition = { xOffset: 5, yOffset: 5 };
|
||||
const firstSectionId = "2";
|
||||
const layoutId = "1";
|
||||
const itemAndSectionPosition = { height: 2, width: 3, yOffset: 2, xOffset: 1 };
|
||||
|
||||
const input = {
|
||||
sections: [
|
||||
createEmptySection("1", 2),
|
||||
firstSection,
|
||||
createEmptySection("3", 1),
|
||||
dynamicSectionInFirst,
|
||||
createDynamicSection({ id: "5", parentSectionId: "3", yOffset: 1 }),
|
||||
],
|
||||
columnCount: 4,
|
||||
} satisfies Pick<Board, "sections" | "columnCount">;
|
||||
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
|
||||
const dynamicSectionInFirstSection = new DynamicSectionMockBuilder({ 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 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)
|
||||
.addItem(itemInFirstSection)
|
||||
.addItem(otherItem)
|
||||
.build();
|
||||
|
||||
const spy = vi.spyOn(emptyPositionModule, "getFirstEmptyPosition");
|
||||
spy.mockReturnValue(emptyPosition);
|
||||
const layoutsSpy = vi.spyOn(boardContext, "getBoardLayouts");
|
||||
layoutsSpy.mockReturnValue([layoutId]);
|
||||
|
||||
// Act
|
||||
const result = createItemCallback({
|
||||
kind: "clock",
|
||||
})(input as unknown as Board);
|
||||
kind: itemKind,
|
||||
})(board);
|
||||
|
||||
const firstSectionResult = result.sections.find((section) => section.id === "2");
|
||||
const item = firstSectionResult?.items.find((item) => item.id !== "12");
|
||||
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
|
||||
expect(spy).toHaveBeenCalledWith([expectedItem, dynamicSectionInFirst], input.columnCount);
|
||||
// Assert
|
||||
expect(result.items.length).toBe(3);
|
||||
const item = result.items.find((item) => item.id !== itemInFirstSection.id && item.id !== otherItem.id);
|
||||
expect(item).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: itemKind,
|
||||
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
|
||||
}),
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
|
||||
layout.columnCount,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,51 +1,63 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { duplicateItemCallback } from "../duplicate-item";
|
||||
import * as emptyPosition from "../empty-position";
|
||||
import { createEmptySection, createItem } from "./shared";
|
||||
import * as emptyPositionModule from "../empty-position";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||
|
||||
describe("item actions duplicate-item", () => {
|
||||
test("should copy it in the same section", () => {
|
||||
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
|
||||
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
|
||||
const currentSection = createEmptySection("2", 1);
|
||||
const currentItem = createItem({
|
||||
id: "1",
|
||||
xOffset: 1,
|
||||
yOffset: 3,
|
||||
width: 3,
|
||||
height: 2,
|
||||
kind: "minecraftServerStatus",
|
||||
// Arrange
|
||||
const itemKind = "minecraftServerStatus";
|
||||
const emptyPosition = { xOffset: 5, yOffset: 5 };
|
||||
const currentSectionId = "2";
|
||||
const layoutId = "1";
|
||||
const currentItemSize = { height: 2, width: 3 };
|
||||
|
||||
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
|
||||
const currentItem = new ItemMockBuilder({
|
||||
kind: itemKind,
|
||||
integrationIds: ["1"],
|
||||
options: { address: "localhost" },
|
||||
advancedOptions: { customCssClasses: ["test"] },
|
||||
});
|
||||
const otherItem = createItem({
|
||||
id: "2",
|
||||
});
|
||||
currentSection.items.push(currentItem, otherItem);
|
||||
const input = {
|
||||
columnCount: 10,
|
||||
sections: [createEmptySection("1", 0), currentSection, createEmptySection("3", 2)],
|
||||
} satisfies Pick<Board, "sections" | "columnCount">;
|
||||
})
|
||||
.addLayout({ layoutId, sectionId: currentSectionId, ...currentItemSize })
|
||||
.build();
|
||||
const otherItem = new ItemMockBuilder({ id: "2" }).addLayout({ layoutId }).build();
|
||||
|
||||
const result = duplicateItemCallback({ itemId: currentItem.id })(input as unknown as Board);
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout(layout)
|
||||
.addItem(currentItem)
|
||||
.addItem(otherItem)
|
||||
.addEmptySection({ id: "1", yOffset: 2 })
|
||||
.addEmptySection({ id: currentSectionId, yOffset: 0 })
|
||||
.addEmptySection({ id: "3", yOffset: 1 })
|
||||
.build();
|
||||
|
||||
const section = result.sections.find((section) => section.id === "2");
|
||||
expect(section?.items.length).toBe(3);
|
||||
const duplicatedItem = section?.items.find((item) => item.id !== currentItem.id && item.id !== otherItem.id);
|
||||
const spy = vi.spyOn(emptyPositionModule, "getFirstEmptyPosition");
|
||||
spy.mockReturnValue(emptyPosition);
|
||||
|
||||
// Act
|
||||
const result = duplicateItemCallback({ itemId: currentItem.id })(board);
|
||||
|
||||
// Assert
|
||||
expect(result.items.length).toBe(3);
|
||||
const duplicatedItem = result.items.find((item) => item.id !== currentItem.id && item.id !== otherItem.id);
|
||||
|
||||
expect(duplicatedItem).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "minecraftServerStatus",
|
||||
xOffset: 5,
|
||||
yOffset: 5,
|
||||
width: 3,
|
||||
height: 2,
|
||||
integrationIds: ["1"],
|
||||
options: { address: "localhost" },
|
||||
advancedOptions: { customCssClasses: ["test"] },
|
||||
kind: itemKind,
|
||||
integrationIds: currentItem.integrationIds,
|
||||
options: currentItem.options,
|
||||
advancedOptions: currentItem.advancedOptions,
|
||||
layouts: [
|
||||
expect.objectContaining({
|
||||
...emptyPosition,
|
||||
...currentItemSize,
|
||||
sectionId: currentSectionId,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("get first empty position", () => {
|
||||
});
|
||||
|
||||
const createElementsFromLayout = (layout: string[][]) => {
|
||||
const elements: (Pick<Item, "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
|
||||
const elements: (Pick<Item["layouts"][number], "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
|
||||
for (let yOffset = 0; yOffset < layout.length; yOffset++) {
|
||||
const row = layout[yOffset];
|
||||
if (!row) continue;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createId } from "@homarr/db";
|
||||
|
||||
import type { Board, DynamicSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||
import { DynamicSectionMockBuilder } from "./dynamic-section-mock";
|
||||
import { EmptySectionMockBuilder } from "./empty-section-mock";
|
||||
import { ItemMockBuilder } from "./item-mock";
|
||||
import { LayoutMockBuilder } from "./layout-mock";
|
||||
|
||||
export class BoardMockBuilder {
|
||||
private readonly board: Board;
|
||||
|
||||
constructor(board?: Partial<Omit<Board, "groupPermissions" | "userPermissions" | "sections" | "items" | "layouts">>) {
|
||||
this.board = {
|
||||
id: createId(),
|
||||
backgroundImageRepeat: "no-repeat",
|
||||
backgroundImageAttachment: "scroll",
|
||||
backgroundImageSize: "cover",
|
||||
backgroundImageUrl: null,
|
||||
primaryColor: "#ffffff",
|
||||
secondaryColor: "#000000",
|
||||
iconColor: null,
|
||||
itemRadius: "lg",
|
||||
pageTitle: "Board",
|
||||
metaTitle: "Board",
|
||||
logoImageUrl: null,
|
||||
faviconImageUrl: null,
|
||||
name: "board",
|
||||
opacity: 100,
|
||||
isPublic: true,
|
||||
disableStatus: false,
|
||||
customCss: "",
|
||||
creatorId: createId(),
|
||||
creator: {
|
||||
id: createId(),
|
||||
image: null,
|
||||
name: "User",
|
||||
},
|
||||
groupPermissions: [],
|
||||
userPermissions: [],
|
||||
sections: [],
|
||||
items: [],
|
||||
layouts: [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
...board,
|
||||
};
|
||||
}
|
||||
|
||||
public addEmptySection(emptySection?: Partial<EmptySection>): BoardMockBuilder {
|
||||
return this.addSection(new EmptySectionMockBuilder(emptySection).build());
|
||||
}
|
||||
|
||||
public addDynamicSection(dynamicSection?: Partial<DynamicSection>): BoardMockBuilder {
|
||||
return this.addSection(new DynamicSectionMockBuilder(dynamicSection).build());
|
||||
}
|
||||
|
||||
public addSection(section: Section): BoardMockBuilder {
|
||||
this.board.sections.push(section);
|
||||
return this;
|
||||
}
|
||||
|
||||
public addSections(sections: Section[]): BoardMockBuilder {
|
||||
this.board.sections.push(...sections);
|
||||
return this;
|
||||
}
|
||||
|
||||
public addItem(item?: Partial<Item>): BoardMockBuilder {
|
||||
this.board.items.push(new ItemMockBuilder(item).build());
|
||||
return this;
|
||||
}
|
||||
|
||||
public addItems(items: Item[]): BoardMockBuilder {
|
||||
this.board.items.push(...items);
|
||||
return this;
|
||||
}
|
||||
|
||||
public addLayout(layout?: Partial<Board["layouts"][number]>): BoardMockBuilder {
|
||||
this.board.layouts.push(new LayoutMockBuilder(layout).build());
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): Board {
|
||||
return this.board;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createId } from "@homarr/db";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export class CategorySectionMockBuilder {
|
||||
private readonly section: CategorySection;
|
||||
|
||||
constructor(section?: Partial<CategorySection>) {
|
||||
this.section = {
|
||||
id: createId(),
|
||||
kind: "category",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
name: "Category",
|
||||
collapsed: false,
|
||||
...section,
|
||||
} satisfies CategorySection;
|
||||
}
|
||||
|
||||
public build(): CategorySection {
|
||||
return this.section;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createId } from "@homarr/db";
|
||||
|
||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export class DynamicSectionMockBuilder {
|
||||
private readonly section: DynamicSection;
|
||||
|
||||
constructor(section?: Partial<DynamicSection>) {
|
||||
this.section = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
layouts: [],
|
||||
...section,
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createId } from "@homarr/db";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export class EmptySectionMockBuilder {
|
||||
private readonly section: EmptySection;
|
||||
|
||||
constructor(section?: Partial<EmptySection>) {
|
||||
this.section = {
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
...section,
|
||||
} satisfies EmptySection;
|
||||
}
|
||||
|
||||
public build(): EmptySection {
|
||||
return this.section;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createId } from "@homarr/db";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export class ItemMockBuilder {
|
||||
private readonly item: Item;
|
||||
|
||||
constructor(item?: Partial<Item>) {
|
||||
this.item = {
|
||||
id: createId(),
|
||||
kind: "app",
|
||||
options: {},
|
||||
layouts: [],
|
||||
integrationIds: [],
|
||||
advancedOptions: {
|
||||
customCssClasses: [],
|
||||
},
|
||||
...item,
|
||||
} satisfies Item;
|
||||
}
|
||||
|
||||
public addLayout(layout?: Partial<Item["layouts"][0]>): ItemMockBuilder {
|
||||
this.item.layouts.push({
|
||||
layoutId: "1",
|
||||
height: 1,
|
||||
width: 1,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
sectionId: "0",
|
||||
...layout,
|
||||
} satisfies Item["layouts"][0]);
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): Item {
|
||||
return this.item;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createId } from "@homarr/db";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export class LayoutMockBuilder {
|
||||
private readonly layout: Board["layouts"][number];
|
||||
|
||||
constructor(layout?: Partial<Board["layouts"][number]>) {
|
||||
this.layout = {
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
...layout,
|
||||
} satisfies Board["layouts"][0];
|
||||
}
|
||||
|
||||
public build(): Board["layouts"][0] {
|
||||
return this.layout;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import { moveAndResizeItemCallback } from "../move-and-resize-item";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
|
||||
describe("moveItemToSectionCallback should move item in section", () => {
|
||||
test("should move item in section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
xOffset: 20,
|
||||
yOffset: 30,
|
||||
width: 15,
|
||||
height: 17,
|
||||
};
|
||||
const itemA = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||
const itemB = new ItemMockBuilder({ id: itemToMove }).addLayout({ layoutId }).addLayout().build();
|
||||
const itemC = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(3);
|
||||
const movedItem = updatedBoard.items.find((item) => item.id === itemToMove);
|
||||
expect(movedItem).not.toBeUndefined();
|
||||
expect(movedItem?.layouts.find((layout) => layout.layoutId === layoutId)).toEqual(
|
||||
expect.objectContaining(newPosition),
|
||||
);
|
||||
const otherItemLayouts = updatedBoard.items
|
||||
.filter((item) => item.id !== itemToMove)
|
||||
.flatMap((item) => item.layouts);
|
||||
expect(otherItemLayouts).not.toContainEqual(expect.objectContaining(newPosition));
|
||||
});
|
||||
test("should not move item if item not found", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
xOffset: 20,
|
||||
yOffset: 30,
|
||||
width: 15,
|
||||
height: 17,
|
||||
};
|
||||
const itemA = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||
const itemC = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(2);
|
||||
expect(updatedBoard.items.find((item) => item.layouts.at(0)?.yOffset === newPosition.yOffset)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import { moveItemToSectionCallback } from "../move-item-to-section";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
|
||||
describe("moveItemToSectionCallback should move item to section", () => {
|
||||
test("should move item to section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
sectionId: "3",
|
||||
xOffset: 20,
|
||||
yOffset: 30,
|
||||
width: 15,
|
||||
height: 17,
|
||||
};
|
||||
const itemA = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||
const itemB = new ItemMockBuilder({ id: itemToMove }).addLayout({ layoutId }).addLayout().build();
|
||||
const itemC = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(3);
|
||||
const movedItem = updatedBoard.items.find((item) => item.id === itemToMove);
|
||||
expect(movedItem).not.toBeUndefined();
|
||||
expect(movedItem?.layouts.find((layout) => layout.layoutId === layoutId)).toEqual(
|
||||
expect.objectContaining(newPosition),
|
||||
);
|
||||
const otherItemLayouts = updatedBoard.items
|
||||
.filter((item) => item.id !== itemToMove)
|
||||
.flatMap((item) => item.layouts);
|
||||
expect(otherItemLayouts).not.toContainEqual(expect.objectContaining(newPosition));
|
||||
});
|
||||
test("should not move item if item not found", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
sectionId: "3",
|
||||
xOffset: 20,
|
||||
yOffset: 30,
|
||||
width: 15,
|
||||
height: 17,
|
||||
};
|
||||
const itemA = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||
const itemC = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(2);
|
||||
expect(updatedBoard.items.find((item) => item.layouts.at(0)?.sectionId === newPosition.sectionId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { removeItemCallback } from "../remove-item";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
|
||||
describe("removeItemCallback should remove item from board", () => {
|
||||
test("should remove correct item from board", () => {
|
||||
// Arrange
|
||||
const itemIdToRemove = "2";
|
||||
const board = new BoardMockBuilder()
|
||||
.addItem({ id: "1" })
|
||||
.addItem({ id: itemIdToRemove })
|
||||
.addItem({ id: "3" })
|
||||
.build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = removeItemCallback({ itemId: itemIdToRemove })(board);
|
||||
|
||||
// Assert
|
||||
const itemIds = updatedBoard.items.map((item) => item.id);
|
||||
expect(itemIds).toHaveLength(2);
|
||||
expect(itemIds).not.toContain(itemIdToRemove);
|
||||
});
|
||||
test("should not remove item if item not found", () => {
|
||||
// Arrange
|
||||
const itemIdToRemove = "2";
|
||||
const board = new BoardMockBuilder().addItem({ id: "1" }).addItem({ id: "3" }).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = removeItemCallback({ itemId: itemIdToRemove })(board);
|
||||
|
||||
// Assert
|
||||
const itemIds = updatedBoard.items.map((item) => item.id);
|
||||
expect(itemIds).toHaveLength(2);
|
||||
expect(itemIds).not.toContain(itemIdToRemove);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { DynamicSection, EmptySection, Item } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const createEmptySection = (id: string, yOffset: number): EmptySection => ({
|
||||
id,
|
||||
kind: "empty",
|
||||
yOffset,
|
||||
xOffset: 0,
|
||||
items: [],
|
||||
});
|
||||
|
||||
export const createDynamicSection = (section: Omit<Partial<DynamicSection>, "kind">): DynamicSection => ({
|
||||
id: section.id ?? "0",
|
||||
kind: "dynamic",
|
||||
parentSectionId: section.parentSectionId ?? "0",
|
||||
height: section.height ?? 1,
|
||||
width: section.width ?? 1,
|
||||
yOffset: section.yOffset ?? 0,
|
||||
xOffset: section.xOffset ?? 0,
|
||||
items: section.items ?? [],
|
||||
});
|
||||
|
||||
export const createItem = (item: Partial<Item>): Item => ({
|
||||
id: item.id ?? "0",
|
||||
width: item.width ?? 1,
|
||||
height: item.height ?? 1,
|
||||
yOffset: item.yOffset ?? 0,
|
||||
xOffset: item.xOffset ?? 0,
|
||||
kind: item.kind ?? "clock",
|
||||
integrationIds: item.integrationIds ?? [],
|
||||
options: item.options ?? {},
|
||||
advancedOptions: item.advancedOptions ?? { customCssClasses: [] },
|
||||
});
|
||||
@@ -3,30 +3,16 @@ import { useCallback } from "react";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import type { CreateItemInput } from "./actions/create-item";
|
||||
import { createItemCallback } from "./actions/create-item";
|
||||
import type { DuplicateItemInput } from "./actions/duplicate-item";
|
||||
import { duplicateItemCallback } from "./actions/duplicate-item";
|
||||
|
||||
interface MoveAndResizeItem {
|
||||
itemId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
interface MoveItemToSection {
|
||||
itemId: string;
|
||||
sectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
interface RemoveItem {
|
||||
itemId: string;
|
||||
}
|
||||
import type { MoveAndResizeItemInput } from "./actions/move-and-resize-item";
|
||||
import { moveAndResizeItemCallback } from "./actions/move-and-resize-item";
|
||||
import type { MoveItemToSectionInput } from "./actions/move-item-to-section";
|
||||
import { moveItemToSectionCallback } from "./actions/move-item-to-section";
|
||||
import type { RemoveItemInput } from "./actions/remove-item";
|
||||
import { removeItemCallback } from "./actions/remove-item";
|
||||
|
||||
interface UpdateItemOptions {
|
||||
itemId: string;
|
||||
@@ -62,164 +48,55 @@ export const useItemActions = () => {
|
||||
|
||||
const updateItemOptions = useCallback(
|
||||
({ itemId, newOptions }: UpdateItemOptions) => {
|
||||
updateBoard((previous) => {
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.map((item) => {
|
||||
// Return same item if item is not the one we're changing
|
||||
if (item.id !== itemId) return item;
|
||||
return {
|
||||
...item,
|
||||
options: newOptions,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
items: previous.items.map((item) => (item.id !== itemId ? item : { ...item, options: newOptions })),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const updateItemAdvancedOptions = useCallback(
|
||||
({ itemId, newAdvancedOptions }: UpdateItemAdvancedOptions) => {
|
||||
updateBoard((previous) => {
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.map((item) => {
|
||||
// Return same item if item is not the one we're changing
|
||||
if (item.id !== itemId) return item;
|
||||
return {
|
||||
...item,
|
||||
advancedOptions: newAdvancedOptions,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
items: previous.items.map((item) =>
|
||||
item.id !== itemId ? item : { ...item, advancedOptions: newAdvancedOptions },
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const updateItemIntegrations = useCallback(
|
||||
({ itemId, newIntegrations }: UpdateItemIntegrations) => {
|
||||
updateBoard((previous) => {
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.map((item) => {
|
||||
// Return same item if item is not the one we're moving
|
||||
if (item.id !== itemId) return item;
|
||||
return {
|
||||
...item,
|
||||
...("integrationIds" in item ? { integrationIds: newIntegrations } : {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const moveAndResizeItem = useCallback(
|
||||
({ itemId, ...positionProps }: MoveAndResizeItem) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.map((item) => {
|
||||
// Return same item if item is not the one we're moving
|
||||
if (item.id !== itemId) return item;
|
||||
return {
|
||||
...item,
|
||||
...positionProps,
|
||||
} satisfies Item;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
items: previous.items.map((item) =>
|
||||
item.id !== itemId || !("integrationIds" in item) ? item : { ...item, integrationIds: newIntegrations },
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const moveAndResizeItem = useCallback(
|
||||
(input: MoveAndResizeItemInput) => {
|
||||
updateBoard(moveAndResizeItemCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const moveItemToSection = useCallback(
|
||||
({ itemId, sectionId, ...positionProps }: MoveItemToSection) => {
|
||||
updateBoard((previous) => {
|
||||
const currentSection = previous.sections.find((section) => section.items.some((item) => item.id === itemId));
|
||||
|
||||
// If item is in the same section (on initial loading) don't do anything
|
||||
if (!currentSection) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const currentItem = currentSection.items.find((item) => item.id === itemId);
|
||||
if (!currentItem) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
if (currentSection.id === sectionId && currentItem.xOffset) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return sections without item if not section where it is moved to
|
||||
if (section.id !== sectionId)
|
||||
return {
|
||||
...section,
|
||||
items: section.items.filter((item) => item.id !== itemId),
|
||||
};
|
||||
|
||||
// Return section and add item to it
|
||||
return {
|
||||
...section,
|
||||
items: section.items
|
||||
.filter((item) => item.id !== itemId)
|
||||
.concat({
|
||||
...currentItem,
|
||||
...positionProps,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
(input: MoveItemToSectionInput) => {
|
||||
updateBoard(moveItemToSectionCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeItem = useCallback(
|
||||
({ itemId }: RemoveItem) => {
|
||||
updateBoard((previous) => {
|
||||
return {
|
||||
...previous,
|
||||
// Filter removed item out of items array
|
||||
sections: previous.sections.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter((item) => item.id !== itemId),
|
||||
})),
|
||||
};
|
||||
});
|
||||
({ itemId }: RemoveItemInput) => {
|
||||
updateBoard(removeItemCallback({ itemId }));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
@@ -11,13 +11,13 @@ import { useSettings } from "@homarr/settings";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: Item;
|
||||
item: SectionItem;
|
||||
}
|
||||
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
@@ -50,7 +50,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
};
|
||||
|
||||
interface InnerContentProps {
|
||||
item: Item;
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { ItemMoveModal } from "./item-move-modal";
|
||||
@@ -21,7 +21,7 @@ export const BoardItemMenu = ({
|
||||
resetErrorBoundary,
|
||||
}: {
|
||||
offset: number;
|
||||
item: Item;
|
||||
item: SectionItem;
|
||||
resetErrorBoundary?: () => void;
|
||||
}) => {
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
|
||||
@@ -7,11 +7,11 @@ import type { GridStack } from "@homarr/gridstack";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
interface InnerProps {
|
||||
gridStack: GridStack;
|
||||
item: Pick<Item, "id" | "xOffset" | "yOffset" | "width" | "height">;
|
||||
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: Omit<InnerProps["item"], "id">) => {
|
||||
(values: Pick<Item["layouts"][number], "height" | "width" | "xOffset" | "yOffset">) => {
|
||||
const gridItem = innerProps.gridStack
|
||||
.getGridItems()
|
||||
.find((item) => item.getAttribute("data-id") === innerProps.item.id);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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[],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user