mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-27 00:40:58 +01:00
fix: add item does not respect dynamic sections (#2010)
* bug: add item does not respect dynamic sections * fix: deepsource issue
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const getFirstEmptyPosition = (
|
||||
elements: Pick<Item, "yOffset" | "xOffset" | "width" | "height">[],
|
||||
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;
|
||||
};
|
||||
@@ -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<Board, "sections" | "columnCount">;
|
||||
|
||||
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<Board, "sections" | "columnCount">;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<Board, "sections" | "columnCount">;
|
||||
|
||||
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"] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<Item, "xOffset" | "yOffset" | "width" | "height"> & { 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;
|
||||
};
|
||||
@@ -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<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: [] },
|
||||
});
|
||||
@@ -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<Item, "yOffset" | "xOffset">,
|
||||
{
|
||||
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<Item, { yOffset?: number; xOffset?: number }>;
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user