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);
|
||||
|
||||
Reference in New Issue
Block a user