diff --git a/apps/nextjs/src/components/board/items/actions/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts new file mode 100644 index 000000000..bc46278a5 --- /dev/null +++ b/apps/nextjs/src/components/board/items/actions/create-item.ts @@ -0,0 +1,62 @@ +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 { getFirstEmptyPosition } from "./empty-position"; + +export interface CreateItemInput { + kind: WidgetKind; +} + +export const createItemCallback = + ({ kind }: CreateItemInput) => + (previous: Board): Board => { + 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]; + 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, + integrationIds: [], + advancedOptions: { + customCssClasses: [], + }, + } satisfies Modify< + Item, + { + kind: WidgetKind; + } + >; + + 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), + }; + }), + }; + }; diff --git a/apps/nextjs/src/components/board/items/actions/duplicate-item.ts b/apps/nextjs/src/components/board/items/actions/duplicate-item.ts new file mode 100644 index 000000000..2e4688d18 --- /dev/null +++ b/apps/nextjs/src/components/board/items/actions/duplicate-item.ts @@ -0,0 +1,81 @@ +import { createId } from "@homarr/db/client"; + +import type { Board, DynamicSection, EmptySection } from "~/app/[locale]/boards/_types"; +import { getFirstEmptyPosition } from "./empty-position"; + +export interface DuplicateItemInput { + itemId: string; +} + +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); + if (!itemToDuplicate) return previous; + + const currentSection = previous.sections.find((section) => section.id === itemToDuplicate.sectionId); + if (!currentSection) return previous; + + 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 = { + ...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), + }; + }), + }; + + return result; + }; diff --git a/apps/nextjs/src/components/board/items/actions/empty-position.ts b/apps/nextjs/src/components/board/items/actions/empty-position.ts new file mode 100644 index 000000000..23e6b1fc4 --- /dev/null +++ b/apps/nextjs/src/components/board/items/actions/empty-position.ts @@ -0,0 +1,25 @@ +import type { Item } from "~/app/[locale]/boards/_types"; + +export const getFirstEmptyPosition = ( + elements: Pick[], + columnCount: number, + rowCount = 9999, + size: { width: number; height: number } = { width: 1, height: 1 }, +) => { + for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) { + for (let xOffset = 0; xOffset < columnCount; xOffset++) { + const isOccupied = elements.some( + (element) => + element.yOffset < yOffset + size.height && + element.yOffset + element.height > yOffset && + element.xOffset < xOffset + size.width && + element.xOffset + element.width > xOffset, + ); + + if (!isOccupied) { + return { xOffset, yOffset }; + } + } + } + return undefined; +}; diff --git a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts new file mode 100644 index 000000000..a0846241a --- /dev/null +++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { Board } from "~/app/[locale]/boards/_types"; +import { createItemCallback } from "../create-item"; +import * as emptyPosition from "../empty-position"; +import { createDynamicSection, createEmptySection, createItem } from "./shared"; + +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; + + const result = createItemCallback({ + kind: "clock", + })(input as unknown as 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); + }); + 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, + }); + + const input = { + sections: [ + createEmptySection("1", 2), + firstSection, + createEmptySection("3", 1), + dynamicSectionInFirst, + createDynamicSection({ id: "5", parentSectionId: "3", yOffset: 1 }), + ], + columnCount: 4, + } satisfies Pick; + + const result = createItemCallback({ + kind: "clock", + })(input as unknown as 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); + }); +}); diff --git a/apps/nextjs/src/components/board/items/actions/test/duplicate-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/duplicate-item.spec.ts new file mode 100644 index 000000000..d02e908c0 --- /dev/null +++ b/apps/nextjs/src/components/board/items/actions/test/duplicate-item.spec.ts @@ -0,0 +1,52 @@ +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"; + +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", + 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; + + const result = duplicateItemCallback({ itemId: currentItem.id })(input as unknown as Board); + + 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); + + expect(duplicatedItem).toEqual( + expect.objectContaining({ + kind: "minecraftServerStatus", + xOffset: 5, + yOffset: 5, + width: 3, + height: 2, + integrationIds: ["1"], + options: { address: "localhost" }, + advancedOptions: { customCssClasses: ["test"] }, + }), + ); + }); +}); diff --git a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts new file mode 100644 index 000000000..37eac9a5f --- /dev/null +++ b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "vitest"; + +import type { Item } from "~/app/[locale]/boards/_types"; +import { getFirstEmptyPosition } from "../empty-position"; + +describe("get first empty position", () => { + test.each([ + [[[" ", " ", " ", " "]], [1, 1], 0, 0], + [[["a", " ", " ", " "]], [1, 1], 1, 0], + [[[" ", "a", " ", " "]], [1, 1], 0, 0], + [ + [ + ["a", "a", " ", " "], + ["a", "a", " ", " "], + ], + [1, 1], + 2, + 0, + ], + [[["a", "a", "a", "a"]], [1, 1], 0, 1], + [ + [ + ["a", "a", "a", "a"], + ["a", "a", "a", "a"], + ], + [1, 1], + 0, + 2, + ], + [ + [ + ["a", "a", " ", "b", "b"], + ["a", "a", " ", "b", "b"], + ], + [1, 2], + 2, + 0, + ], + [ + [ + ["a", "a", " ", " ", "b", "b"], + ["a", "a", " ", " ", "b", "b"], + ], + [2, 2], + 2, + 0, + ], + [ + [ + ["a", "a", " ", "d", "b", "b"], + ["a", "a", "c", "e", "b", "b"], + ], + [1, 1], + 2, + 0, + ], + [ + [ + ["a", "a", " ", " ", "b", "b"], + ["a", "a", " ", "e", "b", "b"], + ], + [2, 2], + 0, + 2, + ], + ])("should return the first empty position", (layout, size, expectedX, expectedY) => { + const elements = createElementsFromLayout(layout); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = getFirstEmptyPosition(elements, layout[0]!.length, undefined, { width: size[0]!, height: size[1]! }); + + expect(result).toEqual({ xOffset: expectedX, yOffset: expectedY }); + }); + + test.each([ + [[[" ", " "]], [1, 1], 0, 0, 1], + [[["a", " "]], [1, 1], 1, 0, 1], + [[["a", "a"]], [1, 1], undefined, undefined, 1], + [[["a", "a"]], [1, 1], 0, 1, 2], + [ + [ + ["a", "b", " ", " "], + ["a", "c", " ", "d"], + ], + [2, 2], + undefined, + undefined, + 3, + ], + [ + [ + ["a", "b", " ", " "], + ["a", "c", " ", "d"], + ], + [2, 2], + 0, + 2, + 4, + ], + [[["a", "b"]], [2, 1], 0, 1, 2], + ])("should return the first empty position with limited rows", (layout, size, expectedX, expectedY, rowCount) => { + const elements = createElementsFromLayout(layout); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = getFirstEmptyPosition(elements, layout[0]!.length, rowCount, { width: size[0]!, height: size[1]! }); + + expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined); + }); +}); + +const createElementsFromLayout = (layout: string[][]) => { + const elements: (Pick & { char: string })[] = []; + for (let yOffset = 0; yOffset < layout.length; yOffset++) { + const row = layout[yOffset]; + if (!row) continue; + for (let xOffset = 0; xOffset < row.length; xOffset++) { + const item = row[xOffset]; + if (item === " " || !item) continue; + + const existing = elements.find((element) => element.char === item); + if (existing) { + existing.height = yOffset - existing.yOffset + 1; + existing.width = xOffset - existing.xOffset + 1; + continue; + } + + elements.push({ + yOffset, + xOffset, + width: 1, + height: 1, + char: item, + }); + } + } + + return elements; +}; diff --git a/apps/nextjs/src/components/board/items/actions/test/shared.ts b/apps/nextjs/src/components/board/items/actions/test/shared.ts new file mode 100644 index 000000000..af3329f49 --- /dev/null +++ b/apps/nextjs/src/components/board/items/actions/test/shared.ts @@ -0,0 +1,32 @@ +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, "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 => ({ + 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: [] }, +}); diff --git a/apps/nextjs/src/components/board/items/item-actions.tsx b/apps/nextjs/src/components/board/items/item-actions.tsx index 4d452574e..c8a93a590 100644 --- a/apps/nextjs/src/components/board/items/item-actions.tsx +++ b/apps/nextjs/src/components/board/items/item-actions.tsx @@ -1,12 +1,13 @@ import { useCallback } from "react"; -import type { Modify } from "@homarr/common/types"; -import { createId } from "@homarr/db/client"; -import type { WidgetKind } from "@homarr/definitions"; import type { BoardItemAdvancedOptions } from "@homarr/validation"; -import type { EmptySection, Item } from "~/app/[locale]/boards/_types"; +import type { Item } from "~/app/[locale]/boards/_types"; import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client"; +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; @@ -42,87 +43,19 @@ interface UpdateItemIntegrations { newIntegrations: string[]; } -interface CreateItem { - kind: WidgetKind; -} - -interface DuplicateItem { - itemId: string; -} - export const useItemActions = () => { const { updateBoard } = useUpdateBoard(); const createItem = useCallback( - ({ kind }: CreateItem) => { - 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 widget = { - id: createId(), - kind, - options: {}, - width: 1, - height: 1, - integrationIds: [], - advancedOptions: { - customCssClasses: [], - }, - } satisfies Modify< - Omit, - { - kind: WidgetKind; - } - >; - - return { - ...previous, - sections: previous.sections.map((section) => { - // Return same section if item is not in it - if (section.id !== lastSection.id) return section; - return { - ...section, - items: section.items.concat(widget as unknown as Item), - }; - }), - }; - }); + (input: CreateItemInput) => { + updateBoard(createItemCallback(input)); }, [updateBoard], ); const duplicateItem = useCallback( - ({ itemId }: DuplicateItem) => { - updateBoard((previous) => { - const itemToDuplicate = previous.sections - .flatMap((section) => section.items) - .find((item) => item.id === itemId); - - if (!itemToDuplicate) return previous; - - const newItem = { - ...itemToDuplicate, - id: createId(), - yOffset: undefined, - xOffset: undefined, - } satisfies Modify; - - 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.concat(newItem as unknown as Item), - }; - }), - }; - }); + ({ itemId }: DuplicateItemInput) => { + updateBoard(duplicateItemCallback({ itemId })); }, [updateBoard], );