merge: stack widget modernization on dnd kit
This commit is contained in:
940 files changed
+104475
-33649
No files matched your search
@@ -144,13 +144,13 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.map((element) =>
|
||||
element instanceof HTMLElement
|
||||
? element.matches(".grid-stack-item-content")
|
||||
? element.matches("[data-advanced-focus-source]")
|
||||
? element
|
||||
: element.closest<HTMLElement>(".grid-stack-item-content")
|
||||
: element.closest<HTMLElement>("[data-advanced-focus-source]")
|
||||
: null,
|
||||
)
|
||||
.find((source) => source !== null && source !== current.source);
|
||||
const itemId = underlyingSource?.closest<HTMLElement>(".grid-stack-item[data-id]")?.dataset.id;
|
||||
const itemId = underlyingSource?.dataset.itemId;
|
||||
if (!underlyingSource || !itemId) return;
|
||||
|
||||
if (underlyingSource.hasAttribute("aria-keyshortcuts")) {
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("AdvancedFocusManualSurface", () => {
|
||||
expect(dialog?.id).toBe("advanced-focus-test");
|
||||
expect(dialog?.getAttribute("aria-modal")).toBe("true");
|
||||
expect(dialog?.closest("[data-portal]")).not.toBeNull();
|
||||
expect(dialog?.closest(".grid-stack-item")).toBeNull();
|
||||
expect(dialog?.closest("[data-grid-item-id]")).toBeNull();
|
||||
expect(document.getElementById(dialog?.getAttribute("aria-labelledby") ?? "")?.textContent).toBe(
|
||||
"Test widget advanced view",
|
||||
);
|
||||
|
||||
@@ -1,50 +1,43 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetDefaultSizes } from "@homarr/definitions";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||
|
||||
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
|
||||
import { getFirstEmptyPosition, getNearestEmptyPosition } from "./empty-position";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getFirstEmptyPosition } from "./empty-position";
|
||||
import { getSectionElements } from "./section-elements";
|
||||
|
||||
export interface BoardPlacementHint {
|
||||
sectionId: string;
|
||||
layoutId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
}
|
||||
|
||||
export interface CreateItemInput {
|
||||
id?: string;
|
||||
kind: WidgetKind;
|
||||
options?: Record<string, unknown>;
|
||||
integrationIds?: string[];
|
||||
advancedOptions?: BoardItemAdvancedOptions;
|
||||
placement?: BoardPlacementHint;
|
||||
}
|
||||
|
||||
export const createItemCallback =
|
||||
({ id, kind, options = {}, integrationIds = [], advancedOptions, placement }: CreateItemInput) =>
|
||||
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const firstSection = previous.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.filter(
|
||||
(section): section is EmptySection =>
|
||||
section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection && !placement) return previous;
|
||||
|
||||
const defaultSectionId = firstSection?.id ?? placement?.sectionId;
|
||||
if (!defaultSectionId) return previous;
|
||||
if (!firstSection) return previous;
|
||||
|
||||
const itemLayouts = createItemLayouts(previous, firstSection, kind);
|
||||
const widget = {
|
||||
id: id ?? createId(),
|
||||
kind,
|
||||
options,
|
||||
layouts: createItemLayouts(previous, defaultSectionId, kind, placement),
|
||||
layouts: itemLayouts,
|
||||
integrationIds,
|
||||
advancedOptions: advancedOptions ?? {
|
||||
advancedOptions: {
|
||||
title: null,
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
@@ -66,31 +59,21 @@ const defaultItemSize = { width: 1, height: 1 };
|
||||
|
||||
const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize;
|
||||
|
||||
const createItemLayouts = (
|
||||
board: Board,
|
||||
defaultSectionId: string,
|
||||
kind: WidgetKind,
|
||||
placement?: BoardPlacementHint,
|
||||
): ItemLayout[] => {
|
||||
const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): ItemLayout[] => {
|
||||
const layouts = getBoardLayouts(board);
|
||||
const itemSize = getWidgetItemSize(kind);
|
||||
|
||||
return layouts.map((layoutId) => {
|
||||
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
||||
const targetSectionId = placement?.layoutId === layoutId ? placement.sectionId : defaultSectionId;
|
||||
const elements = getSectionElements(board, { sectionId: targetSectionId, layoutId });
|
||||
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
||||
const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width;
|
||||
const size = {
|
||||
width: Math.min(itemSize.width, columnCount),
|
||||
height: itemSize.height,
|
||||
};
|
||||
|
||||
const targetSection = board.sections.find((section) => section.id === targetSectionId);
|
||||
const dynamicLayout =
|
||||
targetSection?.kind === "dynamic"
|
||||
? targetSection.layouts.find((layout) => layout.layoutId === layoutId)
|
||||
: undefined;
|
||||
const columnCount = dynamicLayout?.width ?? boardLayout?.columnCount ?? 1;
|
||||
const rowCount = dynamicLayout?.height ?? 9999;
|
||||
const emptyPosition = boardLayout
|
||||
? placement?.layoutId === layoutId
|
||||
? getNearestEmptyPosition(elements, columnCount, rowCount, placement, itemSize)
|
||||
: getFirstEmptyPosition(elements, columnCount, rowCount, itemSize)
|
||||
? getFirstEmptyPosition(elements, columnCount, undefined, size)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) {
|
||||
@@ -98,10 +81,10 @@ const createItemLayouts = (
|
||||
}
|
||||
|
||||
return {
|
||||
width: itemSize.width,
|
||||
height: itemSize.height,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
...emptyPosition,
|
||||
sectionId: targetSectionId,
|
||||
sectionId: currentSection.id,
|
||||
layoutId,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board, EmptySection, ItemLayout, Section } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getFirstEmptyPosition } from "./empty-position";
|
||||
import { getSectionElements } from "./section-elements";
|
||||
|
||||
@@ -15,16 +17,17 @@ export const duplicateItemCallback =
|
||||
if (!itemToDuplicate) return previous;
|
||||
|
||||
const clonedItem = structuredClone(itemToDuplicate);
|
||||
const clonedLayouts = clonedItem.layouts.map((layout) => ({
|
||||
...layout,
|
||||
...getNextPosition(previous, layout),
|
||||
}));
|
||||
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.concat({
|
||||
...clonedItem,
|
||||
id: createId(),
|
||||
layouts: clonedItem.layouts.map((layout) => ({
|
||||
...layout,
|
||||
...getNextPosition(previous, layout),
|
||||
})),
|
||||
layouts: clonedLayouts,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -42,8 +45,10 @@ const getNextPosition = (board: Board, layout: ItemLayout): { xOffset: number; y
|
||||
}
|
||||
|
||||
const firstSection = board.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) {
|
||||
@@ -67,18 +72,21 @@ const getEmptySectionPosition = (
|
||||
layout: ItemLayout,
|
||||
section: Section,
|
||||
): { xOffset: number; yOffset: number } | undefined => {
|
||||
const boardLayout = board.layouts.find((boardLayout) => boardLayout.id === layout.layoutId);
|
||||
const boardLayout = board.layouts.find((candidate) => candidate.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, {
|
||||
const sectionElements = getSectionElements(board, { sectionId: section.id, layoutId: layout.layoutId });
|
||||
if (section.kind === "empty") {
|
||||
const columnCount = getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset));
|
||||
|
||||
const size = {
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
});
|
||||
};
|
||||
return getFirstEmptyPosition(sectionElements, columnCount, undefined, size);
|
||||
}
|
||||
|
||||
const sectionLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layout.layoutId);
|
||||
const sectionLayout = section.layouts.find((candidate) => candidate.layoutId === layout.layoutId);
|
||||
if (!sectionLayout) return;
|
||||
|
||||
return getFirstEmptyPosition(sectionElements, sectionLayout.width, sectionLayout.height, {
|
||||
|
||||
@@ -8,55 +8,18 @@ export const getFirstEmptyPosition = (
|
||||
) => {
|
||||
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
|
||||
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
|
||||
if (isPositionEmpty(elements, { xOffset, yOffset }, size)) {
|
||||
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;
|
||||
};
|
||||
|
||||
export const getNearestEmptyPosition = (
|
||||
elements: Pick<SectionItem, "yOffset" | "xOffset" | "width" | "height">[],
|
||||
columnCount: number,
|
||||
rowCount: number,
|
||||
preferred: { xOffset: number; yOffset: number },
|
||||
size: { width: number; height: number },
|
||||
) => {
|
||||
const maxX = columnCount - size.width;
|
||||
const maxY = rowCount - size.height;
|
||||
if (maxX < 0 || maxY < 0) return undefined;
|
||||
|
||||
const clamped = {
|
||||
xOffset: Math.min(Math.max(0, preferred.xOffset), maxX),
|
||||
yOffset: Math.min(Math.max(0, preferred.yOffset), maxY),
|
||||
};
|
||||
if (isPositionEmpty(elements, clamped, size)) return clamped;
|
||||
|
||||
const lastOccupiedRow = elements.reduce((maximum, element) => Math.max(maximum, element.yOffset + element.height), 0);
|
||||
const searchMaxY = Math.min(maxY, Math.max(clamped.yOffset + 20, lastOccupiedRow + size.height));
|
||||
let best: { xOffset: number; yOffset: number; distance: number } | undefined;
|
||||
|
||||
for (let yOffset = 0; yOffset <= searchMaxY; yOffset++) {
|
||||
for (let xOffset = 0; xOffset <= maxX; xOffset++) {
|
||||
if (!isPositionEmpty(elements, { xOffset, yOffset }, size)) continue;
|
||||
const distance = Math.abs(xOffset - clamped.xOffset) + Math.abs(yOffset - clamped.yOffset);
|
||||
if (!best || distance < best.distance) best = { xOffset, yOffset, distance };
|
||||
}
|
||||
}
|
||||
|
||||
return best && { xOffset: best.xOffset, yOffset: best.yOffset };
|
||||
};
|
||||
|
||||
const isPositionEmpty = (
|
||||
elements: Pick<SectionItem, "yOffset" | "xOffset" | "width" | "height">[],
|
||||
position: { xOffset: number; yOffset: number },
|
||||
size: { width: number; height: number },
|
||||
) =>
|
||||
!elements.some(
|
||||
(element) =>
|
||||
element.yOffset < position.yOffset + size.height &&
|
||||
element.yOffset + element.height > position.yOffset &&
|
||||
element.xOffset < position.xOffset + size.width &&
|
||||
element.xOffset + element.width > position.xOffset,
|
||||
);
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const getSectionElements = (board: Board, { sectionId, layoutId }: { sectionId: string; layoutId: string }) => {
|
||||
const dynamicSectionsOfFirstSection = board.sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
const containersOfSection = board.sections
|
||||
.filter((section) => section.kind === "container")
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
.map(({ layouts, ...section }) => ({ ...section, ...layouts.find((layout) => layout.layoutId === layoutId)! }))
|
||||
.filter((section) => section.parentSectionId === sectionId);
|
||||
@@ -14,5 +14,5 @@ export const getSectionElements = (board: Board, { sectionId, layoutId }: { sect
|
||||
}))
|
||||
.filter((item) => item.sectionId === sectionId);
|
||||
|
||||
return [...items, ...dynamicSectionsOfFirstSection];
|
||||
return [...items, ...containersOfSection];
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import * as boardContext from "@homarr/boards/context";
|
||||
import { createItemCallback } from "../create-item";
|
||||
import * as emptyPositionModule from "../empty-position";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
|
||||
import { ContainerSectionMockBuilder } from "./mocks/container-section-mock";
|
||||
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||
|
||||
@@ -56,9 +56,9 @@ describe("item actions create-item", () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, 9999, { height: 1, width: 1 });
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
|
||||
});
|
||||
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
|
||||
test("should correctly pass containers and items to getFirstEmptyPosition", () => {
|
||||
// Arrange
|
||||
const itemKind = "clock";
|
||||
const emptyPosition = { xOffset: 5, yOffset: 5 };
|
||||
@@ -67,21 +67,21 @@ describe("item actions create-item", () => {
|
||||
const itemAndSectionPosition = { height: 2, width: 3, yOffset: 2, xOffset: 1 };
|
||||
|
||||
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
|
||||
const dynamicSectionInFirstSection = new DynamicSectionMockBuilder({ id: "4" })
|
||||
const containerInFirstSection = new ContainerSectionMockBuilder({ id: "4" })
|
||||
.addLayout({ ...itemAndSectionPosition, layoutId, parentSectionId: firstSectionId })
|
||||
.build();
|
||||
const itemInFirstSection = new ItemMockBuilder({ id: "12" })
|
||||
.addLayout({ ...itemAndSectionPosition, layoutId, sectionId: firstSectionId })
|
||||
.build();
|
||||
const otherDynamicSection = new DynamicSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
|
||||
const otherContainer = new ContainerSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
|
||||
const otherItem = new ItemMockBuilder({ id: "13" }).addLayout({ layoutId }).build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout(layout)
|
||||
.addEmptySection({ id: "1", yOffset: 2 })
|
||||
.addEmptySection({ id: firstSectionId, yOffset: 0 })
|
||||
.addEmptySection({ id: "3", yOffset: 1 })
|
||||
.addSection(dynamicSectionInFirstSection)
|
||||
.addSection(otherDynamicSection)
|
||||
.addSection(containerInFirstSection)
|
||||
.addSection(otherContainer)
|
||||
.addItem(itemInFirstSection)
|
||||
.addItem(otherItem)
|
||||
.build();
|
||||
@@ -98,8 +98,10 @@ describe("item actions create-item", () => {
|
||||
|
||||
// Assert
|
||||
expect(result.items.length).toBe(3);
|
||||
const item = result.items.find((item) => item.id !== itemInFirstSection.id && item.id !== otherItem.id);
|
||||
expect(item).toEqual(
|
||||
const createdItem = result.items.find(
|
||||
(candidate) => candidate.id !== itemInFirstSection.id && candidate.id !== otherItem.id,
|
||||
);
|
||||
expect(createdItem).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: itemKind,
|
||||
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
|
||||
@@ -108,62 +110,35 @@ describe("item actions create-item", () => {
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
|
||||
layout.columnCount,
|
||||
9999,
|
||||
undefined,
|
||||
{ height: 1, width: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the clicked section and nearest cell for the active layout", () => {
|
||||
const activeLayoutId = "desktop";
|
||||
const secondaryLayoutId = "mobile";
|
||||
const rootSectionId = "root";
|
||||
const targetSectionId = "target";
|
||||
const activeLayout = new LayoutMockBuilder({ id: activeLayoutId, columnCount: 12 }).build();
|
||||
const secondaryLayout = new LayoutMockBuilder({ id: secondaryLayoutId, columnCount: 4 }).build();
|
||||
const targetSection = new DynamicSectionMockBuilder({ id: targetSectionId })
|
||||
.addLayout({
|
||||
layoutId: activeLayoutId,
|
||||
parentSectionId: rootSectionId,
|
||||
width: 4,
|
||||
test("clamps wide defaults and places below a full automatic canvas", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 1;
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "existing" })
|
||||
.addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 })
|
||||
.build(),
|
||||
);
|
||||
|
||||
const result = createItemCallback({ id: "created", kind: "mediaMissing" })(board);
|
||||
const created = result.items.find((item) => item.id === "created");
|
||||
|
||||
expect(created?.layouts).toEqual([
|
||||
expect.objectContaining({
|
||||
layoutId: layout.id,
|
||||
sectionId: "canvas",
|
||||
width: 1,
|
||||
height: 3,
|
||||
})
|
||||
.build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout(activeLayout)
|
||||
.addLayout(secondaryLayout)
|
||||
.addEmptySection({ id: rootSectionId, yOffset: 0 })
|
||||
.addSection(targetSection)
|
||||
.build();
|
||||
|
||||
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([activeLayoutId, secondaryLayoutId]);
|
||||
|
||||
const result = createItemCallback({
|
||||
kind: "clock",
|
||||
placement: {
|
||||
sectionId: targetSectionId,
|
||||
layoutId: activeLayoutId,
|
||||
xOffset: 2,
|
||||
yOffset: 1,
|
||||
},
|
||||
})(board);
|
||||
|
||||
expect(result.items.at(0)?.layouts).toEqual([
|
||||
{
|
||||
layoutId: activeLayoutId,
|
||||
sectionId: targetSectionId,
|
||||
xOffset: 2,
|
||||
yOffset: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
layoutId: secondaryLayoutId,
|
||||
sectionId: rootSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
yOffset: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { duplicateItemCallback } from "../duplicate-item";
|
||||
import * as emptyPositionModule from "../empty-position";
|
||||
@@ -7,6 +7,10 @@ import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||
|
||||
describe("item actions duplicate-item", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("should copy it in the same section", () => {
|
||||
// Arrange
|
||||
const itemKind = "minecraftServerStatus";
|
||||
@@ -61,4 +65,28 @@ describe("item actions duplicate-item", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the next automatic canvas row when it is the first available space", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 1;
|
||||
const currentItem = new ItemMockBuilder({ id: "source" })
|
||||
.addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 })
|
||||
.build();
|
||||
board.items.push(currentItem);
|
||||
|
||||
const result = duplicateItemCallback({ itemId: currentItem.id })(board);
|
||||
const duplicated = result.items.find((item) => item.id !== currentItem.id);
|
||||
|
||||
expect(duplicated?.layouts[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
layoutId: layout.id,
|
||||
sectionId: "canvas",
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { getFirstEmptyPosition, getNearestEmptyPosition } from "../empty-position";
|
||||
import { getFirstEmptyPosition } from "../empty-position";
|
||||
|
||||
describe("get first empty position", () => {
|
||||
test.each([
|
||||
@@ -106,39 +106,7 @@ describe("get first empty position", () => {
|
||||
|
||||
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
|
||||
});
|
||||
|
||||
test("does not place a wide item beyond the right edge", () => {
|
||||
const elements = createElementsFromLayout([["a", "a", "a", " "]]);
|
||||
|
||||
expect(getFirstEmptyPosition(elements, 4, undefined, { width: 2, height: 1 })).toEqual({
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("get nearest empty position", () => {
|
||||
test("keeps the preferred cell when it fits", () => {
|
||||
expect(getNearestEmptyPosition([], 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({
|
||||
xOffset: 2,
|
||||
yOffset: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("chooses the nearest available cell when the preferred cell is occupied", () => {
|
||||
const elements = [{ xOffset: 2, yOffset: 1, width: 1, height: 1 }];
|
||||
|
||||
expect(getNearestEmptyPosition(elements, 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({
|
||||
xOffset: 2,
|
||||
yOffset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns undefined when the item cannot fit within a bounded section", () => {
|
||||
expect(getNearestEmptyPosition([], 1, 1, { xOffset: 0, yOffset: 0 }, { width: 2, height: 1 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
const createElementsFromLayout = (layout: string[][]) => {
|
||||
const elements: (Pick<Item["layouts"][number], "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
|
||||
for (let yOffset = 0; yOffset < layout.length; yOffset++) {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getMoveTargets } from "../../item-move-modal";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
import { ContainerSectionMockBuilder } from "./mocks/container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "./mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
|
||||
describe("item move targets", () => {
|
||||
test("offers the canvas, enabled gutters, and nested sections", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected the board mock to contain a layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 6;
|
||||
layout.leftGutterColumnCount = 1;
|
||||
layout.rightGutterColumnCount = 1;
|
||||
|
||||
board.sections.push(
|
||||
new EmptySectionMockBuilder({ id: "canvas", xOffset: 0 }).build(),
|
||||
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
|
||||
new EmptySectionMockBuilder({ id: "right-gutter", xOffset: 1 }).build(),
|
||||
new ContainerSectionMockBuilder({
|
||||
id: "container",
|
||||
options: {
|
||||
title: "",
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
showLabel: true,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
},
|
||||
})
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
parentSectionId: "canvas",
|
||||
width: 2,
|
||||
height: 2,
|
||||
})
|
||||
.build(),
|
||||
);
|
||||
|
||||
const targets = getMoveTargets(
|
||||
board,
|
||||
layout.id,
|
||||
{
|
||||
id: "weather",
|
||||
type: "item",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
canvas: "Dashboard canvas",
|
||||
container: "New section",
|
||||
leftRail: "Left dashboard rail",
|
||||
rightRail: "Right dashboard rail",
|
||||
numbered: (name, index) => `${name}, destination ${index}`,
|
||||
located: (name, location, index) => `${name}, ${location}, destination ${index}`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(targets.map((target) => target.label)).toEqual([
|
||||
"Dashboard canvas, destination 1",
|
||||
"Left dashboard rail, destination 2",
|
||||
"Right dashboard rail, destination 3",
|
||||
"New section, destination 4",
|
||||
]);
|
||||
expect(new Set(targets.map((target) => target.label))).toHaveLength(targets.length);
|
||||
});
|
||||
|
||||
test("does not offer canvases that are narrower than a nested section's contents", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected the board mock to contain a layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 5;
|
||||
layout.leftGutterColumnCount = 2;
|
||||
|
||||
const section = new ContainerSectionMockBuilder({ id: "source" })
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
parentSectionId: "canvas",
|
||||
width: 4,
|
||||
height: 2,
|
||||
})
|
||||
.build();
|
||||
board.sections.push(
|
||||
new EmptySectionMockBuilder({ id: "canvas" }).build(),
|
||||
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
|
||||
section,
|
||||
);
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "nested" })
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
sectionId: section.id,
|
||||
xOffset: 2,
|
||||
width: 2,
|
||||
})
|
||||
.build(),
|
||||
);
|
||||
|
||||
const sourceLayout = section.layouts[0];
|
||||
if (!sourceLayout) throw new Error("Expected the source section to contain a layout");
|
||||
const targets = getMoveTargets(
|
||||
board,
|
||||
layout.id,
|
||||
{ ...sourceLayout, id: section.id, type: "section" },
|
||||
{
|
||||
canvas: "Canvas",
|
||||
container: "Section",
|
||||
leftRail: "Left",
|
||||
rightRail: "Right",
|
||||
numbered: (name, index) => `${name} ${index}`,
|
||||
located: (name, location, index) => `${name} ${location} ${index}`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(targets).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import type { Board, DynamicSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||
import { DynamicSectionMockBuilder } from "./dynamic-section-mock";
|
||||
import type { Board, ContainerSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||
import { ContainerSectionMockBuilder } from "./container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "./empty-section-mock";
|
||||
import { ItemMockBuilder } from "./item-mock";
|
||||
import { LayoutMockBuilder } from "./layout-mock";
|
||||
@@ -45,6 +45,8 @@ export class BoardMockBuilder {
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
@@ -56,8 +58,8 @@ export class BoardMockBuilder {
|
||||
return this.addSection(new EmptySectionMockBuilder(emptySection).build());
|
||||
}
|
||||
|
||||
public addDynamicSection(dynamicSection?: Partial<DynamicSection>): BoardMockBuilder {
|
||||
return this.addSection(new DynamicSectionMockBuilder(dynamicSection).build());
|
||||
public addContainer(container?: Partial<ContainerSection>): BoardMockBuilder {
|
||||
return this.addSection(new ContainerSectionMockBuilder(container).build());
|
||||
}
|
||||
|
||||
public addSection(section: Section): BoardMockBuilder {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
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,38 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import type { ContainerSection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
type ContainerSectionMock = Omit<Partial<ContainerSection>, "options"> & {
|
||||
options?: Partial<ContainerSection["options"]>;
|
||||
};
|
||||
|
||||
export class ContainerSectionMockBuilder {
|
||||
private readonly section: ContainerSection;
|
||||
|
||||
constructor(section?: ContainerSectionMock) {
|
||||
this.section = {
|
||||
id: section?.id ?? createId(),
|
||||
kind: "container",
|
||||
collapsed: section?.collapsed ?? false,
|
||||
layouts: section?.layouts ?? [],
|
||||
options: containerSectionOptionsSchema.parse(section?.options),
|
||||
};
|
||||
}
|
||||
|
||||
public addLayout(layout?: Partial<ContainerSection["layouts"][0]>): ContainerSectionMockBuilder {
|
||||
this.section.layouts.push({
|
||||
layoutId: layout?.layoutId ?? createId(),
|
||||
parentSectionId: layout?.parentSectionId ?? createId(),
|
||||
xOffset: layout?.xOffset ?? 0,
|
||||
yOffset: layout?.yOffset ?? 0,
|
||||
width: layout?.width ?? 1,
|
||||
height: layout?.height ?? 1,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): ContainerSection {
|
||||
return structuredClone(this.section);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
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",
|
||||
options: {
|
||||
title: "",
|
||||
borderColor: "",
|
||||
customCssClasses: [],
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export class LayoutMockBuilder {
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
...layout,
|
||||
} satisfies Board["layouts"][0];
|
||||
|
||||
@@ -10,3 +10,17 @@
|
||||
background-color: var(--background-color) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.editModeInertContent {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.editModeInertContent[data-board-grid-inert-content="true"] {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.editModeInertContent[data-board-grid-inert-content="true"] * {
|
||||
pointer-events: none !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
@@ -1,26 +1,28 @@
|
||||
import type { CSSProperties, KeyboardEvent } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ComponentType, CSSProperties, KeyboardEvent, MutableRefObject } from "react";
|
||||
import { Suspense, use, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Badge, Box, Card, Portal } from "@mantine/core";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { CardProps } from "@mantine/core";
|
||||
import { Badge, Box, Card, Center, Loader, Portal } from "@mantine/core";
|
||||
import { useElementSize, useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetRuntimeRef } from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import {
|
||||
createWidgetRuntimeState,
|
||||
loadWidgetDynamic,
|
||||
reduceWidgetOptionsWithDefaultValues,
|
||||
getWidgetQueryKeys,
|
||||
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
||||
widgetImports,
|
||||
} from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
} from "@homarr/widgets/definition";
|
||||
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
|
||||
@@ -31,35 +33,136 @@ import { redirectShiftWheel } from "../advanced-focus/wheel";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
import { WidgetContextMenu } from "./widget-context-menu";
|
||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||
|
||||
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: SectionItem;
|
||||
}
|
||||
|
||||
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
||||
if (kind === "iframe") return "hidden";
|
||||
if (kind === "systemResources") return "visible";
|
||||
return undefined;
|
||||
const getOverflowFromKind = (kind: SectionItem["kind"], hasCustomCssClasses: boolean) => {
|
||||
if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" } as const;
|
||||
if (kind === "iframe") return { overflowX: "hidden", overflowY: "hidden" } as const;
|
||||
if (hasCustomCssClasses) return {};
|
||||
return { overflowX: "hidden", overflowY: "auto" } as const;
|
||||
};
|
||||
|
||||
type BoardItemCardProps = CardProps & {
|
||||
item: SectionItem;
|
||||
innerRef: (element: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<Card
|
||||
{...cardProps}
|
||||
ref={innerRef}
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-grid-item-content
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"board-grid-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetDefinitionLoadError = ({
|
||||
error,
|
||||
resetErrorBoundary,
|
||||
item,
|
||||
innerRef,
|
||||
}: FallbackProps & Pick<BoardItemCardProps, "item" | "innerRef">) => (
|
||||
<BoardItemCard item={item} innerRef={innerRef}>
|
||||
<Center h="100%">
|
||||
<WidgetError error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</Center>
|
||||
</BoardItemCard>
|
||||
);
|
||||
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const { ref: contentRef, width, height } = useElementSize<HTMLDivElement>();
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
|
||||
const widgetRuntimeRef = useRef(createWidgetRuntimeState());
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<BoardItemCard item={item} innerRef={ref}>
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
</BoardItemCard>
|
||||
}
|
||||
>
|
||||
<LoadedBoardItemContent
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
widgetStateRef={widgetStateRef}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
contentRef={ref}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
interface LoadedBoardItemContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
contentRef: (element: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
const LoadedBoardItemContent = ({
|
||||
item,
|
||||
width,
|
||||
height,
|
||||
widgetStateRef,
|
||||
widgetRuntimeRef,
|
||||
contentRef,
|
||||
}: LoadedBoardItemContentProps) => {
|
||||
const { definition, Component } = use(loadWidgetResources(item.kind));
|
||||
const sourceRef = useRef<HTMLDivElement>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const { width: viewportWidth, height: viewportHeight } = useViewportSize();
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n();
|
||||
const widgetName = t(`widget.${item.kind}.name`);
|
||||
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
|
||||
const [isEditMode] = useEditMode();
|
||||
const widgetRuntimeRef = useRef(createWidgetRuntimeState());
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
||||
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
||||
const { active, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
||||
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(widgetImports[item.kind].definition);
|
||||
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
|
||||
const widgetName = t(`widget.${item.kind}.name`);
|
||||
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
|
||||
const advancedViewId = `advanced-focus-${item.id}`;
|
||||
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
|
||||
const isAdvanced = activeFocus !== null;
|
||||
@@ -70,8 +173,6 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const isPreview = activeFocus?.activation === "preview";
|
||||
const isManual = activeFocus?.activation === "manual";
|
||||
|
||||
// Keep one widget subtree mounted while moving its stable portal target between the grid and viewport layers.
|
||||
// This preserves iframe/editor state and escapes Gridstack's transformed containing block.
|
||||
useIsomorphicEffect(() => {
|
||||
if (!supportsAdvancedFocus) return;
|
||||
const target = document.createElement("div");
|
||||
@@ -80,12 +181,8 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
return () => target.remove();
|
||||
}, [supportsAdvancedFocus]);
|
||||
|
||||
// Responsive layout changes remount grid items. Never leave their focus backdrop orphaned.
|
||||
useEffect(() => () => dismiss(item.id), [dismiss, item.id]);
|
||||
|
||||
const openAdvanced = (autofocusClose = false) => {
|
||||
if (sourceRef.current) open(item.id, sourceRef.current, { activation: "manual", autofocusClose });
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
event.currentTarget !== event.target ||
|
||||
@@ -96,8 +193,9 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
openAdvanced(true);
|
||||
if (sourceRef.current) open(item.id, sourceRef.current, { activation: "manual", autofocusClose: true });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const surface = isManual ? manualSurface : cardRef.current;
|
||||
if (!isAdvanced || !surface) return;
|
||||
@@ -125,7 +223,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
};
|
||||
|
||||
const previewStyle =
|
||||
isPreview && advancedRect
|
||||
isPreview && advancedRect && activeFocus
|
||||
? ({
|
||||
left: advancedRect.left,
|
||||
top: advancedRect.top,
|
||||
@@ -145,6 +243,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
role={isPreview ? "region" : undefined}
|
||||
aria-label={isPreview ? advancedViewLabel : undefined}
|
||||
data-advanced-focus-surface={isPreview || undefined}
|
||||
data-grid-item-content
|
||||
radius={board.itemRadius}
|
||||
p={isAdvanced ? undefined : 0}
|
||||
h={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
||||
@@ -152,6 +251,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"board-grid-item-content",
|
||||
isPreview && advancedFocusClasses.surface,
|
||||
activeFocus?.phase === "closing" && isPreview && advancedFocusClasses.surfaceClosing,
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
@@ -160,29 +260,60 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
root: {
|
||||
"--opacity": isAdvanced ? 0.98 : board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: getOverflowFromKind(item.kind),
|
||||
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
style={previewStyle}
|
||||
>
|
||||
<Box ref={contentRef} w="100%" h="100%" mih={0}>
|
||||
<InnerContent
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
displayMode={isAdvanced ? "advanced" : "compact"}
|
||||
/>
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
{isEditMode && (
|
||||
<BoardItemMenu item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
|
||||
)}
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<InnerContent
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
widgetStateRef={widgetStateRef}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
displayMode={isAdvanced ? "advanced" : "compact"}
|
||||
definition={definition}
|
||||
Component={Component}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WidgetContextMenu item={item} widgetRuntimeRef={widgetRuntimeRef} sourceRef={sourceRef}>
|
||||
<WidgetContextMenu
|
||||
item={item}
|
||||
definition={definition}
|
||||
widgetStateRef={widgetStateRef}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
sourceRef={sourceRef}
|
||||
>
|
||||
<Box
|
||||
ref={sourceRef}
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-advanced-focus-source
|
||||
data-item-id={item.id}
|
||||
tabIndex={!isEditMode && supportsAdvancedFocus ? 0 : undefined}
|
||||
role={!isEditMode && supportsAdvancedFocus ? "group" : undefined}
|
||||
aria-label={
|
||||
@@ -198,7 +329,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
onPointerLeave={() => {
|
||||
if (supportsAdvancedFocus) leave(item.id);
|
||||
}}
|
||||
className={combineClasses("grid-stack-item-content", isAdvanced && advancedFocusClasses.sourcePlaceholder)}
|
||||
className={combineClasses(isAdvanced && advancedFocusClasses.sourcePlaceholder)}
|
||||
>
|
||||
<div ref={(node) => mountPortalTarget(node, "compact")} className={advancedFocusClasses.surfaceHost}>
|
||||
{!supportsAdvancedFocus && widgetCard}
|
||||
@@ -212,7 +343,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
{isManual && advancedRect && closePosition && (
|
||||
{isManual && activeFocus && advancedRect && closePosition && (
|
||||
<AdvancedFocusManualSurface
|
||||
opened={activeFocus.phase === "visible"}
|
||||
phase={activeFocus.phase}
|
||||
@@ -262,37 +393,47 @@ interface InnerContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
displayMode: "compact" | "advanced";
|
||||
definition: WidgetDefinition;
|
||||
Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>;
|
||||
}
|
||||
|
||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const Comp = loadWidgetDynamic(item.kind);
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options);
|
||||
const newItem = { ...item, options };
|
||||
const { updateItemOptions } = useItemActions();
|
||||
const { removeItem, updateItemOptions } = useItemActions();
|
||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
updateItemOptions({ itemId: item.id, newOptions });
|
||||
const widgetSupportsIntegrations =
|
||||
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
|
||||
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1;
|
||||
const queryClient = useQueryClient();
|
||||
const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind);
|
||||
|
||||
return (
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={() => {
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
removeWidgetDataQueries(queryClient, widgetQueryKeys);
|
||||
reset();
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
{isEditMode && (
|
||||
<BoardItemMenu item={newItem} definition={definition} resetErrorBoundary={resetErrorBoundary} />
|
||||
)}
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -304,23 +445,31 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||
}
|
||||
/>
|
||||
<BoardItemMenu offset={4} item={newItem} />
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
{isEditMode && <BoardItemMenu item={newItem} definition={definition} />}
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
data-homarr-widget-ready={item.kind}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<Component
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
removeItem={() => removeItem({ itemId: item.id })}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||
|
||||
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";
|
||||
import { useOpenItemMoveModal } from "./item-move-modal";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
|
||||
interface BoardItemMenuProps {
|
||||
offset: number;
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
resetErrorBoundary?: () => void;
|
||||
}
|
||||
|
||||
export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
if (!session) return null;
|
||||
const [isEditMode] = useEditMode();
|
||||
if (!session || !isEditMode) return null;
|
||||
|
||||
return <BoardItemMenuInner {...props} />;
|
||||
};
|
||||
|
||||
const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
const tItem = useScopedI18n("item");
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const { integrations: integrationData = [], section } = useSectionContext();
|
||||
const settings = useSettings();
|
||||
const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
|
||||
// Reset error boundary on next render if item has been edited
|
||||
useEffect(() => {
|
||||
@@ -52,12 +52,11 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
}
|
||||
}, [item, resetErrorBoundary]);
|
||||
|
||||
if (!isEditMode || isPending) return null;
|
||||
|
||||
const openEditModal = () => {
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: item.advancedOptions,
|
||||
options: item.options,
|
||||
@@ -78,19 +77,19 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
});
|
||||
refResetErrorBoundaryOnNextRender.current = true;
|
||||
},
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
integrationData: integrationData.filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
"supportedIntegrations" in definition &&
|
||||
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
integrationSupport: "supportedIntegrations" in definition,
|
||||
settings,
|
||||
itemId: item.id,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
{
|
||||
title(t) {
|
||||
return `${t("item.edit.title")} - ${t(`widget.${item.kind}.name`)}`;
|
||||
title(translate) {
|
||||
return `${translate("item.edit.title")} - ${translate(`widget.${item.kind}.name`)}`;
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -107,37 +106,48 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||
<Menu withinPortal position="right-start" arrowPosition="center" opened={isMenuOpen} onChange={setIsMenuOpen}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius={"xl"}
|
||||
size={24}
|
||||
radius="sm"
|
||||
pos="absolute"
|
||||
top={offset}
|
||||
right={offset}
|
||||
style={{ zIndex: "var(--mantine-z-index-popover)" }}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
top={12}
|
||||
right={8}
|
||||
style={{ zIndex: 10 }}
|
||||
data-board-widget-settings
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
<IconDotsVertical size="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
|
||||
<Menu.Item
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={openEditModal}
|
||||
onFocus={preloadWidgetEditModal}
|
||||
onPointerEnter={preloadWidgetEditModal}
|
||||
>
|
||||
{tItem("action.edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onClick={() => {
|
||||
if (!gridstack.current) return;
|
||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||
openMoveModal({
|
||||
entry: item,
|
||||
sourceSectionId: section.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
{canDuplicate && (
|
||||
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
|
||||
@@ -1,94 +1,220 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Grid, NumberInput, Stack } from "@mantine/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Grid, NumberInput, Select, Stack } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { GridStack } from "@homarr/gridstack";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { getLayoutRowCount } from "../layout";
|
||||
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
|
||||
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
|
||||
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
|
||||
|
||||
interface InnerProps {
|
||||
gridStack: GridStack;
|
||||
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
|
||||
columnCount: number;
|
||||
export type MovableEntry = Pick<
|
||||
SectionItem | ContainerSectionItem,
|
||||
"id" | "type" | "width" | "height" | "xOffset" | "yOffset"
|
||||
>;
|
||||
|
||||
export interface OpenItemMoveModalInput {
|
||||
entry: MovableEntry;
|
||||
sourceSectionId: string;
|
||||
}
|
||||
|
||||
interface InnerProps extends OpenItemMoveModalInput {
|
||||
board: Board;
|
||||
currentLayoutId: string;
|
||||
commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void;
|
||||
}
|
||||
|
||||
interface MoveTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
}
|
||||
|
||||
interface MoveTargetLabels {
|
||||
canvas: string;
|
||||
container: string;
|
||||
leftRail: string;
|
||||
rightRail: string;
|
||||
numbered: (name: string, index: number) => string;
|
||||
located: (name: string, location: string, index: number) => string;
|
||||
}
|
||||
|
||||
type MoveTargetCandidate = Omit<MoveTarget, "label"> & {
|
||||
name: string;
|
||||
location?: string;
|
||||
};
|
||||
|
||||
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
|
||||
const tLandmark = useScopedI18n("board.landmark");
|
||||
const tContainer = useScopedI18n("section.container");
|
||||
const tMoveResize = useScopedI18n("item.moveResize");
|
||||
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
|
||||
const minimumSize = useMemo(
|
||||
() => getEntryMinimumSize(board, currentLayoutId, entry),
|
||||
[board, currentLayoutId, entry],
|
||||
);
|
||||
const targets = useMemo(
|
||||
() =>
|
||||
getMoveTargets(board, currentLayoutId, entry, {
|
||||
canvas: tLandmark("canvas"),
|
||||
container: tContainer("action.create"),
|
||||
leftRail: tLandmark("leftRail"),
|
||||
rightRail: tLandmark("rightRail"),
|
||||
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
|
||||
located: (name, location, index) =>
|
||||
tMoveResize("target.located", {
|
||||
name,
|
||||
location,
|
||||
index: String(index),
|
||||
}),
|
||||
}),
|
||||
[board, currentLayoutId, entry, tContainer, tLandmark, tMoveResize],
|
||||
);
|
||||
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
|
||||
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
|
||||
|
||||
if (!initialTarget) {
|
||||
throw new Error("No valid dashboard section is available for this item");
|
||||
}
|
||||
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
xOffset: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(innerProps.columnCount - 1),
|
||||
yOffset: z.number().min(0),
|
||||
width: z.number().min(1).max(maxWidthRef.current),
|
||||
height: z.number().min(1),
|
||||
sectionId: z.string().min(1),
|
||||
xOffset: z.number().int().min(0).max(32767),
|
||||
yOffset: z.number().int().min(0).max(32767),
|
||||
width: z.number().int().min(1).max(32767),
|
||||
height: z.number().int().min(1).max(32767),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
xOffset: innerProps.item.xOffset,
|
||||
yOffset: innerProps.item.yOffset,
|
||||
width: innerProps.item.width,
|
||||
height: innerProps.item.height,
|
||||
},
|
||||
onValuesChange(values, previous) {
|
||||
if (values.xOffset !== previous.xOffset) {
|
||||
maxWidthRef.current = innerProps.columnCount - values.xOffset;
|
||||
}
|
||||
sectionId: initialTarget.id,
|
||||
xOffset: entry.xOffset,
|
||||
yOffset: entry.yOffset,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
},
|
||||
},
|
||||
);
|
||||
const selectedTarget = targetById.get(form.values.sectionId) ?? initialTarget;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: Pick<Item["layouts"][number], "height" | "width" | "xOffset" | "yOffset">) => {
|
||||
const gridItem = innerProps.gridStack
|
||||
.getGridItems()
|
||||
.find((item) => item.getAttribute("data-id") === innerProps.item.id);
|
||||
if (!gridItem) return;
|
||||
innerProps.gridStack.update(gridItem, {
|
||||
h: values.height,
|
||||
w: values.width,
|
||||
x: values.xOffset,
|
||||
(values: { sectionId: string; xOffset: number; yOffset: number; width: number; height: number }) => {
|
||||
const target = targetById.get(values.sectionId);
|
||||
if (!target) return;
|
||||
|
||||
const width = Math.max(minimumSize.width, Math.min(values.width, target.columnCount));
|
||||
const height = Math.max(minimumSize.height, values.height);
|
||||
const nextPlacement: SectionGridPlacement = {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
x: Math.min(values.xOffset, target.columnCount - width),
|
||||
y: values.yOffset,
|
||||
});
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
const targetPlacements = getSectionGridPlacements(board, currentLayoutId, target.id).filter(
|
||||
(placement) => placement.id !== entry.id,
|
||||
);
|
||||
const resolvedTarget = resolvePinnedGridCollisions(
|
||||
[...targetPlacements, nextPlacement],
|
||||
nextPlacement,
|
||||
target.columnCount,
|
||||
);
|
||||
if (target.maxRowCount !== null && getLayoutRowCount(resolvedTarget) > target.maxRowCount) {
|
||||
form.setFieldError("height", tMoveResize("keyboard.boundary"));
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshots =
|
||||
sourceSectionId === target.id
|
||||
? [{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget }]
|
||||
: [
|
||||
{
|
||||
layoutId: currentLayoutId,
|
||||
sectionId: sourceSectionId,
|
||||
placements: getSectionGridPlacements(board, currentLayoutId, sourceSectionId).filter(
|
||||
(placement) => placement.id !== entry.id,
|
||||
),
|
||||
},
|
||||
{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget },
|
||||
];
|
||||
|
||||
commitSectionGrids(snapshots);
|
||||
actions.closeModal();
|
||||
focusMovedEntry(entry.id, target.id);
|
||||
},
|
||||
[actions, innerProps.gridStack, innerProps.item.id],
|
||||
[
|
||||
actions,
|
||||
board,
|
||||
commitSectionGrids,
|
||||
currentLayoutId,
|
||||
entry,
|
||||
form,
|
||||
minimumSize,
|
||||
sourceSectionId,
|
||||
targetById,
|
||||
tMoveResize,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit, console.error)}>
|
||||
<Stack>
|
||||
<Select
|
||||
data={targets.map((target) => ({ value: target.id, label: target.label }))}
|
||||
label={tMoveResize("field.section.label")}
|
||||
data-autofocus
|
||||
allowDeselect={false}
|
||||
searchable
|
||||
{...form.getInputProps("sectionId")}
|
||||
/>
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.xOffset.label")}
|
||||
data-autofocus
|
||||
label={tMoveResize("field.xOffset.label")}
|
||||
min={0}
|
||||
max={innerProps.columnCount - 1}
|
||||
max={selectedTarget.columnCount - 1}
|
||||
{...form.getInputProps("xOffset")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput label={t("item.moveResize.field.yOffset.label")} min={0} {...form.getInputProps("yOffset")} />
|
||||
<NumberInput
|
||||
label={tMoveResize("field.yOffset.label")}
|
||||
min={0}
|
||||
max={selectedTarget.maxRowCount === null ? undefined : selectedTarget.maxRowCount - 1}
|
||||
{...form.getInputProps("yOffset")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.width.label")}
|
||||
min={1}
|
||||
max={innerProps.columnCount - form.values.xOffset}
|
||||
label={tMoveResize("field.width.label")}
|
||||
min={minimumSize.width}
|
||||
max={selectedTarget.columnCount - Math.min(form.values.xOffset, selectedTarget.columnCount - 1)}
|
||||
{...form.getInputProps("width")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput label={t("item.moveResize.field.height.label")} min={1} {...form.getInputProps("height")} />
|
||||
<NumberInput
|
||||
label={tMoveResize("field.height.label")}
|
||||
min={minimumSize.height}
|
||||
max={
|
||||
selectedTarget.maxRowCount === null
|
||||
? undefined
|
||||
: selectedTarget.maxRowCount - Math.min(form.values.yOffset, selectedTarget.maxRowCount - 1)
|
||||
}
|
||||
{...form.getInputProps("height")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<ModalFormFooter onCancel={actions.closeModal} />
|
||||
@@ -101,3 +227,137 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
|
||||
export const useOpenItemMoveModal = () => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { commitSectionGrids } = useGridLayoutActions();
|
||||
const { openModal } = useModalAction(ItemMoveModal);
|
||||
|
||||
return useCallback(
|
||||
(input: OpenItemMoveModalInput) => {
|
||||
openModal({
|
||||
...input,
|
||||
board,
|
||||
currentLayoutId,
|
||||
commitSectionGrids,
|
||||
});
|
||||
},
|
||||
[board, commitSectionGrids, currentLayoutId, openModal],
|
||||
);
|
||||
};
|
||||
|
||||
export const getMoveTargets = (
|
||||
board: Board,
|
||||
layoutId: string,
|
||||
entry: MovableEntry,
|
||||
labels: MoveTargetLabels,
|
||||
): MoveTarget[] => {
|
||||
const minimum = getEntryMinimumSize(board, layoutId, entry);
|
||||
|
||||
const candidates = board.sections.flatMap((section): MoveTargetCandidate[] => {
|
||||
if (section.kind === "empty") {
|
||||
const columnCount = getSectionColumnCount(board, layoutId, section.id);
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
if (columnCount === 0) return [];
|
||||
return minimum.width <= columnCount
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
|
||||
columnCount,
|
||||
maxRowCount: getSectionMaxRowCount(board, layoutId, section.id),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) return [];
|
||||
if (
|
||||
(entry.type === "section" && isSameOrDescendant(board, layoutId, section.id, entry.id)) ||
|
||||
minimum.width > layout.width ||
|
||||
minimum.height > layout.height
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: section.id,
|
||||
name: section.options.title || labels.container,
|
||||
columnCount: layout.width,
|
||||
maxRowCount: layout.height,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return candidates.map(({ name, location, ...target }, index) => ({
|
||||
...target,
|
||||
label: location ? labels.located(name, location, index + 1) : labels.numbered(name, index + 1),
|
||||
}));
|
||||
};
|
||||
|
||||
const getSectionColumnCount = (board: Board, layoutId: string, sectionId: string) => {
|
||||
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
|
||||
if (!layout) throw new Error(`Layout "${layoutId}" was not found`);
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) throw new Error(`Section "${sectionId}" was not found`);
|
||||
|
||||
const columnCount = getSectionGridColumnCount(board, layoutId, sectionId);
|
||||
if (columnCount === null) throw new Error(`Section "${sectionId}" has no current layout`);
|
||||
return columnCount;
|
||||
};
|
||||
|
||||
const getSectionMaxRowCount = (board: Board, layoutId: string, sectionId: string) => {
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) return null;
|
||||
if (section.kind === "container") {
|
||||
return section.layouts.find((candidate) => candidate.layoutId === layoutId)?.height ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getEntryMinimumSize = (board: Board, layoutId: string, entry: MovableEntry) => {
|
||||
if (entry.type === "item") return { width: 1, height: 1 };
|
||||
const children = getSectionGridPlacements(board, layoutId, entry.id);
|
||||
return {
|
||||
width: Math.max(1, ...children.map((child) => child.x + child.w)),
|
||||
height: Math.max(1, ...children.map((child) => child.y + child.h)),
|
||||
};
|
||||
};
|
||||
|
||||
const isSameOrDescendant = (board: Board, layoutId: string, candidateId: string, ancestorId: string) => {
|
||||
let current: string | undefined = candidateId;
|
||||
const visited = new Set<string>();
|
||||
while (current && !visited.has(current)) {
|
||||
if (current === ancestorId) return true;
|
||||
visited.add(current);
|
||||
const section = board.sections.find((candidate) => candidate.id === current && candidate.kind === "container");
|
||||
if (!section || section.kind !== "container") return false;
|
||||
current = section.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const focusMovedEntry = (entryId: string, sectionId: string) => {
|
||||
let attempts = 0;
|
||||
const tryFocus = () => {
|
||||
const section = Array.from(document.querySelectorAll<HTMLElement>("[data-grid-section-id]")).find(
|
||||
(candidate) => candidate.dataset.gridSectionId === sectionId,
|
||||
);
|
||||
const entry = Array.from(section?.querySelectorAll<HTMLElement>(":scope > [data-grid-item-id]") ?? []).find(
|
||||
(candidate) => candidate.dataset.gridItemId === entryId,
|
||||
);
|
||||
const editorEntry = entry?.querySelector<HTMLElement>("[data-editor-grid-entry]");
|
||||
if (editorEntry) {
|
||||
editorEntry.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
if (attempts < 10) requestAnimationFrame(tryFocus);
|
||||
};
|
||||
|
||||
requestAnimationFrame(tryFocus);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
|
||||
|
||||
describe("widget selection lock", () => {
|
||||
it("rejects repeated selection synchronously until the active selection finishes", () => {
|
||||
const lock = { current: false };
|
||||
|
||||
expect(tryLockSelection(lock)).toBe(true);
|
||||
expect(tryLockSelection(lock)).toBe(false);
|
||||
|
||||
unlockSelection(lock);
|
||||
expect(tryLockSelection(lock)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMatchingIntegrationsAsync", () => {
|
||||
it("waits for the shared integration query before a cold integration-backed selection", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => [
|
||||
{ id: "matching", kind: "sonarr" as const },
|
||||
{ id: "other", kind: "radarr" as const },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
}),
|
||||
).resolves.toEqual([{ id: "matching", kind: "sonarr" }]);
|
||||
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not wait for integrations when the widget has no integration support", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]);
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: false,
|
||||
supportedIntegrations: [],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
expect(ensureDataAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
export const tryLockSelection = (lock: { current: boolean }) => {
|
||||
if (lock.current) return false;
|
||||
lock.current = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const unlockSelection = (lock: { current: boolean }) => {
|
||||
lock.current = false;
|
||||
};
|
||||
|
||||
export const resolveMatchingIntegrationsAsync = async <TIntegration extends { kind: IntegrationKind }>({
|
||||
hasIntegrationSupport,
|
||||
supportedIntegrations,
|
||||
currentData,
|
||||
ensureDataAsync,
|
||||
}: {
|
||||
hasIntegrationSupport: boolean;
|
||||
supportedIntegrations: readonly IntegrationKind[];
|
||||
currentData: TIntegration[] | undefined;
|
||||
ensureDataAsync: () => Promise<TIntegration[]>;
|
||||
}) => {
|
||||
if (!hasIntegrationSupport) return [];
|
||||
|
||||
const integrations = currentData ?? (await ensureDataAsync());
|
||||
return integrations.filter((integration) => supportedIntegrations.includes(integration.kind));
|
||||
};
|
||||
@@ -1,25 +1,20 @@
|
||||
.selectionCard {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.selectionActions {
|
||||
opacity: 1;
|
||||
.action {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.card:hover .action,
|
||||
.card:focus-within .action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.selectionActions {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.selectionCard:hover .selectionActions,
|
||||
.selectionCard:focus-within .selectionActions {
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.action {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.selectionActions {
|
||||
.action {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { usePersistBoard } from "@homarr/boards/updater";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { createId } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
@@ -13,68 +14,55 @@ import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import type { BoardPlacementHint, CreateItemInput } from "./actions/create-item";
|
||||
import { createItemCallback } from "./actions/create-item";
|
||||
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
import classes from "./item-select-modal.module.css";
|
||||
|
||||
interface ItemSelectModalProps {
|
||||
placement?: BoardPlacementHint;
|
||||
board: { id: string; name: string };
|
||||
persistImmediately: boolean;
|
||||
interface ItemSelectModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
integrationData: RouterOutputs["integration"]["all"] | undefined;
|
||||
customWidgetDefs: RouterOutputs["customWidget"]["available"] | undefined;
|
||||
ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export const ItemSelectModal = createModal<ItemSelectModalProps>(({ actions, innerProps }) => {
|
||||
const ItemSelectModalContent = ({
|
||||
actions,
|
||||
integrationData,
|
||||
customWidgetDefs,
|
||||
ensureIntegrationDataAsync,
|
||||
isAdmin,
|
||||
}: ItemSelectModalContentProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||
const selectionLock = useRef(false);
|
||||
const t = useI18n();
|
||||
const { createItem } = useItemActions();
|
||||
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery({ boardId: innerProps.board.id });
|
||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const settings = useSettings();
|
||||
const { updateAndPersistBoard } = usePersistBoard(innerProps.board);
|
||||
|
||||
const createAndPersist = (input: CreateItemInput) => {
|
||||
try {
|
||||
if (!innerProps.persistImmediately) {
|
||||
createItem(input);
|
||||
return;
|
||||
}
|
||||
updateAndPersistBoard(createItemCallback(input), {
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("item.create.notification.error.title"),
|
||||
message: t("item.create.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("item.create.notification.error.title"),
|
||||
message: t("item.create.notification.error.message"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
objectEntries(widgetImports)
|
||||
.filter(([kind]) => kind !== "customApi")
|
||||
.map(([kind, value]) => ({
|
||||
kind,
|
||||
supportedIntegrations:
|
||||
"supportedIntegrations" in value.definition
|
||||
? value.definition.supportedIntegrations.filter((integration) => integration !== "mock")
|
||||
: [],
|
||||
icon: value.definition.icon,
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
}))
|
||||
widgetKinds
|
||||
.filter((kind) => kind !== "customApi")
|
||||
.map((kind) => {
|
||||
return {
|
||||
kind,
|
||||
supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter(
|
||||
(integration) => integration !== "mock",
|
||||
),
|
||||
icon: widgetCatalogIcons[kind],
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
};
|
||||
})
|
||||
.sort((itemA, itemB) => {
|
||||
if (itemA.kind === "app") return -1;
|
||||
if (itemB.kind === "app") return 1;
|
||||
@@ -95,67 +83,112 @@ export const ItemSelectModal = createModal<ItemSelectModalProps>(({ actions, inn
|
||||
}, [items, search]);
|
||||
|
||||
const filteredCustomWidgets = useMemo(
|
||||
() =>
|
||||
(customWidgetDefs ?? []).filter((def) => def.enabled && def.name.toLowerCase().includes(search.toLowerCase())),
|
||||
() => (customWidgetDefs ?? []).filter((def) => def.name.toLowerCase().includes(search.toLowerCase())),
|
||||
[customWidgetDefs, search],
|
||||
);
|
||||
|
||||
const handleAddCustomWidget = (definitionId: string) => {
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
|
||||
createAndPersist({
|
||||
id: itemId,
|
||||
kind: "customApi",
|
||||
integrationIds: [],
|
||||
options: { ...defaultOptions, definitionId },
|
||||
placement: innerProps.placement,
|
||||
const notifyDefinitionLoadError = (error: unknown) => {
|
||||
showErrorNotification({
|
||||
title: t("common.error"),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const handleAdd = (kind: WidgetKind) => {
|
||||
const definition = widgetImports[kind].definition;
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
|
||||
const matchingIntegrations = hasIntegrationSupport
|
||||
? (integrationData ?? []).filter((integration) =>
|
||||
(definition.supportedIntegrations as string[]).includes(integration.kind),
|
||||
)
|
||||
: [];
|
||||
|
||||
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
|
||||
|
||||
actions.closeModal();
|
||||
|
||||
openEditModal(
|
||||
{
|
||||
kind,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options: defaultOptions,
|
||||
integrationIds,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
createAndPersist({
|
||||
id: itemId,
|
||||
kind,
|
||||
const handleAddCustomWidget = async (customWidgetDefinition: NonNullable<typeof customWidgetDefs>[number]) => {
|
||||
if (!tryLockSelection(selectionLock)) return;
|
||||
setLoadingSelection(`custom:${customWidgetDefinition.id}`);
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
const definition = await loadWidgetDefinition("customApi");
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
const options = {
|
||||
...defaultOptions,
|
||||
definitionId: customWidgetDefinition.id,
|
||||
configuration: customWidgetDefinition.defaultOptions,
|
||||
configurationVersion: customWidgetDefinition.updatedAt.getTime(),
|
||||
};
|
||||
actions.closeModal();
|
||||
openEditModal(
|
||||
{
|
||||
kind: "customApi",
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options,
|
||||
integrationIds: newIntegrationIds,
|
||||
advancedOptions,
|
||||
placement: innerProps.placement,
|
||||
});
|
||||
integrationIds: [],
|
||||
},
|
||||
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: configuredOptions });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
},
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
settings,
|
||||
},
|
||||
integrationData: matchingIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
boardId: innerProps.board.id,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||
},
|
||||
);
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
notifyDefinitionLoadError(error);
|
||||
} finally {
|
||||
unlockSelection(selectionLock);
|
||||
setLoadingSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (kind: WidgetKind) => {
|
||||
if (!tryLockSelection(selectionLock)) return;
|
||||
setLoadingSelection(kind);
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
const definition = await loadWidgetDefinition(kind);
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
|
||||
const matchingIntegrations = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport,
|
||||
supportedIntegrations: definition.supportedIntegrations ?? [],
|
||||
currentData: integrationData,
|
||||
ensureDataAsync: ensureIntegrationDataAsync,
|
||||
});
|
||||
|
||||
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
|
||||
createItem({ id: itemId, kind, integrationIds });
|
||||
actions.closeModal();
|
||||
|
||||
openEditModal(
|
||||
{
|
||||
kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options: defaultOptions,
|
||||
integrationIds,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
updateItemOptions({ itemId, newOptions: options });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
||||
},
|
||||
integrationData: matchingIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
notifyDefinitionLoadError(error);
|
||||
} finally {
|
||||
unlockSelection(selectionLock);
|
||||
setLoadingSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -164,9 +197,9 @@ export const ItemSelectModal = createModal<ItemSelectModalProps>(({ actions, inn
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredItems.length === 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
handleAdd(filteredItems[0]!.kind);
|
||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||
const [item] = filteredItems;
|
||||
if (item) void handleAdd(item.kind);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -174,12 +207,18 @@ export const ItemSelectModal = createModal<ItemSelectModalProps>(({ actions, inn
|
||||
<WidgetItem
|
||||
key={item.kind}
|
||||
item={item}
|
||||
onSelect={() => handleAdd(item.kind)}
|
||||
onSelect={() => void handleAdd(item.kind)}
|
||||
onIntent={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === item.kind}
|
||||
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredCustomWidgets.length > 0 && (
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
@@ -188,18 +227,19 @@ export const ItemSelectModal = createModal<ItemSelectModalProps>(({ actions, inn
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<Card key={def.id} h={selectGridCardHeight} withBorder pos="relative" className={classes.selectionCard}>
|
||||
<Card
|
||||
key={def.id}
|
||||
className={classes.card}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden" }}
|
||||
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{def.iconUrl ? (
|
||||
<Image
|
||||
src={def.iconUrl}
|
||||
alt=""
|
||||
w={22}
|
||||
h={22}
|
||||
fit="contain"
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
) : (
|
||||
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
)}
|
||||
@@ -208,37 +248,78 @@ export const ItemSelectModal = createModal<ItemSelectModalProps>(({ actions, inn
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description}
|
||||
{def.description ?? ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
className={classes.action}
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
className={classes.selectionActions}
|
||||
style={{
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => handleAddCustomWidget(def.id)} variant="light" size="xs" fullWidth>
|
||||
<Button
|
||||
onClick={() => void handleAddCustomWidget(def)}
|
||||
variant="light"
|
||||
size="xs"
|
||||
fullWidth
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === `custom:${def.id}`}
|
||||
>
|
||||
{t("item.create.addToBoard")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredItems.length === 0 && filteredCustomWidgets.length === 0 && (
|
||||
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
);
|
||||
}).withOptions({
|
||||
};
|
||||
const ItemSelectModalFrame = ({
|
||||
actions,
|
||||
innerProps,
|
||||
}: {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: { boardId: string };
|
||||
}) => {
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery(
|
||||
{ boardId: innerProps.boardId },
|
||||
{ enabled: isAdmin },
|
||||
);
|
||||
|
||||
return (
|
||||
<ItemSelectModalContent
|
||||
actions={actions}
|
||||
integrationData={integrationData}
|
||||
customWidgetDefs={customWidgetDefs}
|
||||
ensureIntegrationDataAsync={() => utils.integration.all.ensureData()}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ItemSelectModal = createModal<{ boardId: string }>((props) => (
|
||||
<ItemSelectModalFrame {...props} />
|
||||
)).withOptions({
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
size: modalSizeSelect,
|
||||
});
|
||||
@@ -246,6 +327,9 @@ export const ItemSelectModal = createModal<ItemSelectModalProps>(({ actions, inn
|
||||
const WidgetItem = ({
|
||||
item,
|
||||
onSelect,
|
||||
onIntent,
|
||||
disabled,
|
||||
loading,
|
||||
hasMatchingIntegration,
|
||||
}: {
|
||||
item: {
|
||||
@@ -256,20 +340,27 @@ const WidgetItem = ({
|
||||
icon: TablerIcon;
|
||||
};
|
||||
onSelect: () => void;
|
||||
onIntent: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
hasMatchingIntegration: boolean;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={classes.card}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
className={classes.selectionCard}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
|
||||
borderWidth: hasMatchingIntegration ? 2 : undefined,
|
||||
}}
|
||||
onFocus={onIntent}
|
||||
onPointerEnter={onIntent}
|
||||
aria-busy={loading || undefined}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
@@ -286,17 +377,17 @@ const WidgetItem = ({
|
||||
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
||||
</Stack>
|
||||
<Box
|
||||
className={classes.action}
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
className={classes.selectionActions}
|
||||
style={{
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth disabled={disabled} loading={loading}>
|
||||
{t(`item.create.addToBoard`)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
||||
|
||||
let widgetEditModalPromise: Promise<typeof import("@homarr/widgets/modals")> | undefined;
|
||||
|
||||
export const loadWidgetEditModal = () => {
|
||||
if (widgetEditModalPromise) return widgetEditModalPromise;
|
||||
|
||||
const currentPromise = import("@homarr/widgets/modals");
|
||||
widgetEditModalPromise = currentPromise;
|
||||
void currentPromise.catch(() => {
|
||||
if (widgetEditModalPromise === currentPromise) widgetEditModalPromise = undefined;
|
||||
});
|
||||
return currentPromise;
|
||||
};
|
||||
|
||||
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
|
||||
|
||||
interface LazyWidgetEditModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: WidgetEditModalProps<WidgetKind>;
|
||||
}
|
||||
|
||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||
const Component = WidgetEditModal.component;
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>((props) => (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<Center py="xl">
|
||||
<WidgetError
|
||||
error={error}
|
||||
resetErrorBoundary={() => {
|
||||
widgetEditModalPromise = undefined;
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<LazyWidgetEditModalContent {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)).withOptions({
|
||||
keepMounted: true,
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -1,18 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import type { MutableRefObject, ReactNode, RefObject } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCircleCheck,
|
||||
IconCopy,
|
||||
IconLayoutKanban,
|
||||
IconMaximize,
|
||||
IconRefresh,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
@@ -22,60 +13,56 @@ import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { usePersistBoard } from "@homarr/boards/updater";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets";
|
||||
import {
|
||||
getWidgetQueryKeys,
|
||||
getWidgetRuntimeQueries,
|
||||
reduceWidgetOptionsWithDefaultValues,
|
||||
supportsAdvancedFocus,
|
||||
widgetImports,
|
||||
} from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
||||
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
|
||||
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useAdvancedFocus } from "../advanced-focus/context";
|
||||
import { useBoardPermissions } from "../permissions/client";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { ItemMoveModal } from "./item-move-modal";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||
|
||||
interface WidgetContextMenuProps {
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
sourceRef: RefObject<HTMLElement | null>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const WidgetContextMenu = ({ item, widgetRuntimeRef, sourceRef, children }: WidgetContextMenuProps) => {
|
||||
export const WidgetContextMenu = ({
|
||||
item,
|
||||
definition,
|
||||
widgetRuntimeRef,
|
||||
sourceRef,
|
||||
children,
|
||||
}: WidgetContextMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const { updateAndPersistBoard } = usePersistBoard(board);
|
||||
const tItem = useScopedI18n("item");
|
||||
const tMenu = useScopedI18n("item.menu.label");
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const isRightClickEnabled = settings.enableRightClickOnWidgets;
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const currentDefinition = useMemo(
|
||||
() => widgetImports[item.kind].definition as WidgetDefinition & { kind: string },
|
||||
[item.kind],
|
||||
);
|
||||
const canOpenAdvancedFocus = supportsAdvancedFocus(currentDefinition);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: hasSupportedIntegrations,
|
||||
});
|
||||
const canConfigureWidget =
|
||||
hasChangeAccess && (item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false));
|
||||
const canOpenAdvancedFocus = supportsAdvancedFocus(definition);
|
||||
const queryClient = useQueryClient();
|
||||
const { open: openAdvancedFocus } = useAdvancedFocus();
|
||||
const integrationsWithInteractAccess = useIntegrationsWithInteractAccess();
|
||||
@@ -96,12 +83,10 @@ export const WidgetContextMenu = ({ item, widgetRuntimeRef, sourceRef, children
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options),
|
||||
[item.kind, settings, item.options],
|
||||
() => reduceWidgetOptionsWithDefinition(definition, settings, item.options),
|
||||
[definition, settings, item.options],
|
||||
);
|
||||
|
||||
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(currentDefinition), [currentDefinition]);
|
||||
const widgetQueryMatcher = "queryMatcher" in currentDefinition ? currentDefinition.queryMatcher : undefined;
|
||||
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
||||
const matchesWidgetQuery = useCallback(
|
||||
(queryKey: QueryKey) =>
|
||||
matchesWidgetItemQuery(
|
||||
@@ -114,9 +99,9 @@ export const WidgetContextMenu = ({ item, widgetRuntimeRef, sourceRef, children
|
||||
options,
|
||||
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
|
||||
},
|
||||
widgetQueryMatcher,
|
||||
definition.queryMatcher,
|
||||
),
|
||||
[board.id, item.id, item.integrationIds, options, widgetQueryKeys, widgetQueryMatcher, widgetRuntimeRef],
|
||||
[board.id, definition.queryMatcher, item.id, item.integrationIds, options, widgetQueryKeys, widgetRuntimeRef],
|
||||
);
|
||||
const isWidgetFetching =
|
||||
useIsFetching({
|
||||
@@ -134,11 +119,6 @@ export const WidgetContextMenu = ({ item, widgetRuntimeRef, sourceRef, children
|
||||
|
||||
const setItemOptions = useCallback(
|
||||
(partial: Record<string, unknown>) => {
|
||||
if (isEditMode) {
|
||||
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
|
||||
return;
|
||||
}
|
||||
|
||||
persistBoard((previous) => ({
|
||||
...previous,
|
||||
items: previous.items.map((boardItem) =>
|
||||
@@ -146,18 +126,18 @@ export const WidgetContextMenu = ({ item, widgetRuntimeRef, sourceRef, children
|
||||
),
|
||||
}));
|
||||
},
|
||||
[isEditMode, item.id, options, persistBoard, updateItemOptions],
|
||||
[item.id, persistBoard],
|
||||
);
|
||||
|
||||
type OptionDef = { type: string; skipContextMenu?: boolean };
|
||||
const toggleOptions = useMemo(() => {
|
||||
if (!hasChangeAccess) return [];
|
||||
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
|
||||
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
|
||||
}, [currentDefinition, hasChangeAccess, settings]);
|
||||
if (!canConfigureWidget) return [];
|
||||
const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>;
|
||||
return Object.entries(rawOptions).filter(([, option]) => option.type === "switch" && !option.skipContextMenu);
|
||||
}, [canConfigureWidget, definition, settings]);
|
||||
|
||||
const widgetContextActions =
|
||||
currentDefinition.contextActions?.({
|
||||
definition.contextActions?.({
|
||||
options,
|
||||
setOptions: setItemOptions,
|
||||
integrationIds: item.integrationIds,
|
||||
@@ -174,38 +154,34 @@ export const WidgetContextMenu = ({ item, widgetRuntimeRef, sourceRef, children
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: item.advancedOptions,
|
||||
options: item.options,
|
||||
integrationIds: item.integrationIds,
|
||||
},
|
||||
onSuccessfulEdit: (editResult) => {
|
||||
if (isEditMode) {
|
||||
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
|
||||
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
|
||||
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
|
||||
} else {
|
||||
persistBoard((previous) => ({
|
||||
...previous,
|
||||
items: previous.items.map((boardItem) =>
|
||||
boardItem.id !== item.id
|
||||
? boardItem
|
||||
: {
|
||||
...boardItem,
|
||||
options: editResult.options,
|
||||
advancedOptions: editResult.advancedOptions,
|
||||
integrationIds: editResult.integrationIds,
|
||||
},
|
||||
),
|
||||
}));
|
||||
}
|
||||
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
|
||||
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
|
||||
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
|
||||
persistBoard((previous) => ({
|
||||
...previous,
|
||||
items: previous.items.map((boardItem) =>
|
||||
boardItem.id !== item.id
|
||||
? boardItem
|
||||
: {
|
||||
...boardItem,
|
||||
options: editResult.options,
|
||||
advancedOptions: editResult.advancedOptions,
|
||||
integrationIds: editResult.integrationIds,
|
||||
},
|
||||
),
|
||||
}));
|
||||
},
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
integrationData: integrationData.filter((integration) =>
|
||||
definition.supportedIntegrations?.some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
integrationSupport: definition.supportedIntegrations !== undefined,
|
||||
settings,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
@@ -213,16 +189,15 @@ export const WidgetContextMenu = ({ item, widgetRuntimeRef, sourceRef, children
|
||||
{ title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` },
|
||||
);
|
||||
}, [
|
||||
openModal,
|
||||
definition,
|
||||
integrationData,
|
||||
item,
|
||||
updateItemOptions,
|
||||
openModal,
|
||||
persistBoard,
|
||||
settings,
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
integrationData,
|
||||
currentDefinition,
|
||||
settings,
|
||||
isEditMode,
|
||||
persistBoard,
|
||||
updateItemOptions,
|
||||
]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
@@ -230,10 +205,9 @@ export const WidgetContextMenu = ({ item, widgetRuntimeRef, sourceRef, children
|
||||
[setItemOptions],
|
||||
);
|
||||
|
||||
if (!session) return <>{children}</>;
|
||||
if (!isRightClickEnabled) return <>{children}</>;
|
||||
if (!session || !settings.enableRightClickOnWidgets || isEditMode) return <>{children}</>;
|
||||
|
||||
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
|
||||
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
@@ -255,13 +229,13 @@ export const WidgetContextMenu = ({ item, widgetRuntimeRef, sourceRef, children
|
||||
onClick={() => {
|
||||
if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current);
|
||||
}}
|
||||
disabled={isEditMode}
|
||||
>
|
||||
{t("item.advancedFocus.open")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{toggleOptions.length > 0 && (
|
||||
<>
|
||||
<Menu.Label>{tMenu("options")}</Menu.Label>
|
||||
@@ -296,77 +270,32 @@ export const WidgetContextMenu = ({ item, widgetRuntimeRef, sourceRef, children
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEditMode && (
|
||||
<>
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onClick={() => {
|
||||
if (!gridstack.current) return;
|
||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||
}}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={() => duplicateItem({ itemId: item.id })}
|
||||
>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<>
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
||||
onClick={handleRefetch}
|
||||
disabled={isWidgetFetching}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
{tMenu("refresh")}
|
||||
<WidgetQueryStatus
|
||||
queryClient={queryClient}
|
||||
matchesQuery={matchesWidgetQuery}
|
||||
isFetching={isWidgetFetching}
|
||||
t={t}
|
||||
/>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
{hasChangeAccess && (
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconSettings size={16} />}
|
||||
onClick={openEditModal}
|
||||
disabled={isPending}
|
||||
>
|
||||
{tMenu("settings")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
|
||||
{isEditMode && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => {
|
||||
openConfirmModal({
|
||||
title: tItem("remove.title"),
|
||||
children: tItem("remove.message"),
|
||||
onConfirm: () => removeItem({ itemId: item.id }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tItem("action.remove")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
||||
onClick={handleRefetch}
|
||||
disabled={isWidgetFetching}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
{tMenu("refresh")}
|
||||
<WidgetQueryStatus
|
||||
queryClient={queryClient}
|
||||
matchesQuery={matchesWidgetQuery}
|
||||
isFetching={isWidgetFetching}
|
||||
t={t}
|
||||
/>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconSettings size={16} />}
|
||||
onClick={openEditModal}
|
||||
onFocus={preloadWidgetEditModal}
|
||||
onPointerEnter={preloadWidgetEditModal}
|
||||
disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)}
|
||||
>
|
||||
{tMenu("settings")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
@@ -408,8 +337,7 @@ const WidgetQueryStatus = ({ queryClient, matchesQuery, isFetching, t }: WidgetQ
|
||||
);
|
||||
}
|
||||
|
||||
const hasError = queries.some((q) => q.state.status === "error");
|
||||
|
||||
const hasError = queries.some((query) => query.state.status === "error");
|
||||
if (hasError) {
|
||||
return (
|
||||
<Tooltip label={t("item.menu.status.error")} position="left">
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export type Translator = {
|
||||
(key: string): string;
|
||||
};
|
||||
|
||||
export interface HoverMetadataItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const maxMetadataItems = 3;
|
||||
|
||||
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
|
||||
app: true,
|
||||
anchorNote: true,
|
||||
dynamicSelect: true,
|
||||
sortableItemList: true,
|
||||
multiReleasesRepositories: true,
|
||||
umamiEventName: true,
|
||||
umamiEventNames: true,
|
||||
umamiWebsite: true,
|
||||
};
|
||||
|
||||
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
|
||||
|
||||
type OptionDefinitionWithRules = WidgetOptionDefinition & {
|
||||
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
|
||||
options?: readonly { value: unknown; label: unknown }[];
|
||||
};
|
||||
|
||||
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
|
||||
|
||||
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
|
||||
const match = definition.options?.find((option) => option.value === value);
|
||||
const label = match?.label;
|
||||
const fallbackLabel = () => String(value);
|
||||
const labelResolvers: Record<string, () => string> = {
|
||||
function: () => (label as (translator: Translator) => string)(t),
|
||||
string: () => label as string,
|
||||
missing: fallbackLabel,
|
||||
};
|
||||
const labelType = typeof label;
|
||||
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
|
||||
return resolveLabel();
|
||||
};
|
||||
|
||||
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
|
||||
switch: (value, _, t) => {
|
||||
const switchLabels: Record<string, string> = {
|
||||
true: t("common.action.show"),
|
||||
false: t("common.action.hide"),
|
||||
};
|
||||
return switchLabels[String(Boolean(value))] ?? null;
|
||||
},
|
||||
text: (value) => {
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
},
|
||||
number: (value) => String(value),
|
||||
slider: (value) => String(value),
|
||||
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
|
||||
multiSelect: (value) => {
|
||||
const items = value as unknown[];
|
||||
return items.length > 0 ? String(items.length) : null;
|
||||
},
|
||||
multiText: (value) => {
|
||||
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
|
||||
return items.length > 0 ? items.join(", ") : null;
|
||||
},
|
||||
location: (value) => {
|
||||
const location = value as { name?: string };
|
||||
const name = location.name?.trim() ?? "";
|
||||
return name.length > 0 ? name : null;
|
||||
},
|
||||
};
|
||||
|
||||
const formatOptionValue = (
|
||||
type: WidgetOptionType,
|
||||
value: unknown,
|
||||
definition: OptionDefinitionWithRules,
|
||||
t: Translator,
|
||||
) => {
|
||||
const formatter = optionTypeFormatters[type];
|
||||
return formatter?.(value, definition, t) ?? null;
|
||||
};
|
||||
|
||||
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
|
||||
const labelKey = `widget.${kind}.option.${key}.label`;
|
||||
const translated = t(labelKey);
|
||||
return translated === labelKey ? key : translated;
|
||||
};
|
||||
|
||||
export const collectWidgetHoverMetadata = (
|
||||
item: SectionItem,
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
integrationKinds: IntegrationKind[],
|
||||
t: Translator,
|
||||
): HoverMetadataItem[] => {
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
|
||||
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
|
||||
return objectEntries(optionDefinitions)
|
||||
.filter(([key, optionDefinition]) => {
|
||||
const skip = skippedOptionTypes[optionDefinition.type];
|
||||
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
|
||||
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
|
||||
return !skip && !hidden && !unchanged;
|
||||
})
|
||||
.map(([key, optionDefinition]) => {
|
||||
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
|
||||
return {
|
||||
label: getOptionLabel(item.kind, key, t),
|
||||
value: formattedValue ?? "",
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.value.length > 0)
|
||||
.slice(0, maxMetadataItems);
|
||||
};
|
||||
|
||||
interface DisplayNameSource {
|
||||
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
|
||||
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
const displayNameSources: DisplayNameSource[] = [
|
||||
{
|
||||
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
|
||||
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
|
||||
},
|
||||
{
|
||||
matches: (item, options) =>
|
||||
item.kind === "clock" &&
|
||||
Boolean(options.customTitleToggle) &&
|
||||
String(options.customTitle ?? "").trim().length > 0,
|
||||
resolve: (_, options) => String(options.customTitle).trim(),
|
||||
},
|
||||
];
|
||||
|
||||
export const resolveWidgetDisplayName = (
|
||||
item: SectionItem,
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
t: Translator,
|
||||
) => {
|
||||
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
|
||||
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
|
||||
};
|
||||
@@ -2,19 +2,18 @@
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: calc(100% - 10px);
|
||||
margin-bottom: 1px;
|
||||
z-index: var(--mantine-z-index-app);
|
||||
top: 10px;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
transform: translateY(-6px);
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
|
||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
|
||||
:global([data-type="item"]:not([data-kind="app"]):hover) .wrapper,
|
||||
:global([data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
@@ -32,7 +31,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
padding: 4px 36px 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--background-color);
|
||||
@@ -101,6 +100,11 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metadataLabel {
|
||||
color: var(--mantine-color-dimmed);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.panel {
|
||||
border-color: CanvasText;
|
||||
@@ -110,11 +114,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.metadataLabel {
|
||||
color: var(--mantine-color-dimmed);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metadataValue {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import { Avatar, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { loadWidgetDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import classes from "./widget-hover-overlay.module.css";
|
||||
@@ -18,8 +19,19 @@ interface WidgetHoverOverlayProps {
|
||||
|
||||
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoadedWidgetHoverOverlay item={item} integrations={integrations} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
||||
const t = useI18n();
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const definition = use(loadWidgetDefinition(item.kind));
|
||||
const WidgetIcon = definition.icon;
|
||||
|
||||
const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
@@ -28,12 +40,8 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro
|
||||
item.integrationIds.includes(integration.id),
|
||||
);
|
||||
|
||||
if (!isEditMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<div className={classes.wrapper} data-board-widget-header>
|
||||
<div className={classes.panel}>
|
||||
<div className={classes.nameSection}>
|
||||
<WidgetIcon size={14} stroke={1.75} className={classes.widgetIcon} />
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||
|
||||
describe("removePersistedWidgetQueries", () => {
|
||||
test("removes stale widget queries and allows fresh data to be fetched", async () => {
|
||||
describe("removeWidgetDataQueries", () => {
|
||||
test("removes only the failed widget prefix and allows fresh data to be fetched", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
||||
const sameWidgetQueryKey = [["widget", "mediaServer", "getLibraries"], { input: {} }];
|
||||
const unrelatedWidgetQueryKey = [["widget", "calendar", "getEvents"], { input: {} }];
|
||||
const appQueryKey = [["app", "byId"], { input: {} }];
|
||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||
|
||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||
queryClient.setQueryData(sameWidgetQueryKey, "related");
|
||||
queryClient.setQueryData(unrelatedWidgetQueryKey, "other-widget");
|
||||
queryClient.setQueryData(appQueryKey, "app");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
removeWidgetDataQueries(queryClient, [[["widget", "mediaServer"]]]);
|
||||
|
||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(sameWidgetQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(unrelatedWidgetQueryKey)).toBe("other-widget");
|
||||
expect(queryClient.getQueryData(appQueryKey)).toBe("app");
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
await expect(
|
||||
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
|
||||
).resolves.toBe("fresh");
|
||||
});
|
||||
|
||||
test("removes persisted queries outside the widget router", () => {
|
||||
test("uses an explicit supporting query prefix without purging other supporting data", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
|
||||
const appQueryKey = [["app", "byId"], { input: {} }];
|
||||
@@ -31,14 +40,14 @@ describe("removePersistedWidgetQueries", () => {
|
||||
queryClient.setQueryData(appQueryKey, "stale");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
removeWidgetDataQueries(queryClient, [[["docker", "getContainers"]]]);
|
||||
|
||||
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(appQueryKey)).toBe("stale");
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
});
|
||||
|
||||
test("removes persisted widget queries when the widget kind and query path differ", () => {
|
||||
test("uses the definition query prefix when the widget kind and query path differ", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
|
||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||
@@ -46,7 +55,7 @@ describe("removePersistedWidgetQueries", () => {
|
||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
removeWidgetDataQueries(queryClient, [[["widget", "anchorNotes"]]]);
|
||||
|
||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
|
||||
|
||||
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
|
||||
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
|
||||
export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: readonly QueryKey[]) => {
|
||||
queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey }));
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
# Fixed dashboard geometry
|
||||
|
||||
The board uses fixed logical units:
|
||||
|
||||
- one `1 × 1` item is exactly `200 × 200` logical pixels;
|
||||
- the gap is always `24` logical pixels, giving a `224` pixel track pitch;
|
||||
- multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`);
|
||||
- the canvas is transformed once with one uniform scale derived from its available width.
|
||||
|
||||
Items keep the same logical dimensions at every viewport. The viewport reserves
|
||||
the painted size while the inner canvas remains at its logical dimensions, so a
|
||||
widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay
|
||||
stable.
|
||||
|
||||
The read-only renderer should:
|
||||
|
||||
1. convert persisted `xOffset/yOffset/width/height` values to `x/y/w/h`;
|
||||
2. apply collapsed container heights and call `getCollapsedDisplayLayout`;
|
||||
3. render DOM children in `sortGridPlacementsForReadingOrder` order;
|
||||
4. position children with `getLogicalItemStyle`;
|
||||
5. wrap them with `getScaledCanvasStyles` and the read-only semantic helpers.
|
||||
|
||||
Edit mode can use any interaction library, but its final coordinates should be
|
||||
normalized through this core. `placeGridItem` is the deterministic
|
||||
keyboard/numeric fallback: the active item is pinned while collisions move out
|
||||
of the way and all other items compact vertically. Pointer coordinates from a
|
||||
scaled editor can be converted with `toLogicalCanvasPoint`.
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { BoardLane } from "@homarr/definitions";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { getLogicalTrackSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry";
|
||||
import { getCollapsedDisplayLayout } from "./reflow";
|
||||
import type { GridPlacement } from "./types";
|
||||
|
||||
export { getBoardLaneColumnCount };
|
||||
|
||||
export const getRootSectionForLane = (board: Board, lane: BoardLane): EmptySection | undefined => {
|
||||
const roots = board.sections.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === lane,
|
||||
);
|
||||
if (roots.length > 1) {
|
||||
throw new Error(`Board "${board.id}" has multiple ${lane} canvas roots`);
|
||||
}
|
||||
|
||||
return roots[0];
|
||||
};
|
||||
|
||||
export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) => {
|
||||
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
|
||||
if (!layout) return 0;
|
||||
|
||||
const rowCounts = (["left", "main", "right"] as const).flatMap((lane) => {
|
||||
const columnCount = getBoardLaneColumnCount(layout, lane);
|
||||
const root = getRootSectionForLane(board, lane);
|
||||
if (columnCount === 0 || !root) return [];
|
||||
|
||||
const placements: GridPlacement[] = [
|
||||
...board.items.flatMap((item) => {
|
||||
const itemLayout = item.layouts.find(
|
||||
(candidate) => candidate.layoutId === layoutId && candidate.sectionId === root.id,
|
||||
);
|
||||
return itemLayout
|
||||
? [
|
||||
normalizeGridPlacement(
|
||||
{
|
||||
id: item.id,
|
||||
type: "item" as const,
|
||||
x: itemLayout.xOffset,
|
||||
y: itemLayout.yOffset,
|
||||
w: itemLayout.width,
|
||||
h: itemLayout.height,
|
||||
},
|
||||
columnCount,
|
||||
),
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "container") return [];
|
||||
const sectionLayout = section.layouts.find(
|
||||
(candidate) => candidate.layoutId === layoutId && candidate.parentSectionId === root.id,
|
||||
);
|
||||
return sectionLayout
|
||||
? [
|
||||
normalizeGridPlacement(
|
||||
{
|
||||
id: section.id,
|
||||
type: "section" as const,
|
||||
x: sectionLayout.xOffset,
|
||||
y: sectionLayout.yOffset,
|
||||
w: sectionLayout.width,
|
||||
h: sectionLayout.height,
|
||||
},
|
||||
columnCount,
|
||||
),
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
];
|
||||
const collapsedSectionIds = new Set(
|
||||
board.sections
|
||||
.filter(
|
||||
(section) =>
|
||||
section.kind === "container" &&
|
||||
section.collapsed &&
|
||||
section.options.collapsible &&
|
||||
placements.some((placement) => placement.id === section.id),
|
||||
)
|
||||
.map((section) => section.id),
|
||||
);
|
||||
const displayPlacements =
|
||||
collapsedSectionIds.size > 0
|
||||
? getCollapsedDisplayLayout(placements, { columnCount, collapsedItemIds: collapsedSectionIds })
|
||||
: placements;
|
||||
|
||||
return [getLayoutRowCount(displayPlacements)];
|
||||
});
|
||||
|
||||
return getLogicalTrackSize(Math.max(0, ...rowCounts));
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* A grid unit is a fixed logical size. The canvas is scaled as one surface;
|
||||
* individual cards never change their internal layout at different viewports.
|
||||
*/
|
||||
export const LOGICAL_GRID_CELL_SIZE = 200;
|
||||
export const LOGICAL_GRID_GAP = 24;
|
||||
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
|
||||
export const MIN_ACCESSIBLE_CANVAS_SCALE = 0.75;
|
||||
|
||||
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { LOGICAL_GRID_CELL_SIZE, LOGICAL_GRID_GAP, LOGICAL_GRID_PITCH } from "./constants";
|
||||
import type { GridCoordinates, GridPlacement } from "./types";
|
||||
|
||||
export const getLogicalTrackSize = (trackCount: number) => {
|
||||
const normalizedTrackCount = Math.max(0, toFiniteNumber(trackCount, "trackCount"));
|
||||
if (normalizedTrackCount === 0) return 0;
|
||||
|
||||
return normalizedTrackCount * LOGICAL_GRID_CELL_SIZE + (normalizedTrackCount - 1) * LOGICAL_GRID_GAP;
|
||||
};
|
||||
|
||||
export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSProperties => ({
|
||||
position: "absolute",
|
||||
left: x * LOGICAL_GRID_PITCH,
|
||||
top: y * LOGICAL_GRID_PITCH,
|
||||
width: getLogicalTrackSize(w),
|
||||
height: getLogicalTrackSize(h),
|
||||
});
|
||||
|
||||
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
|
||||
placements.reduce((rowCount, placement) => Math.max(rowCount, placement.y + placement.h), 0);
|
||||
|
||||
export const normalizeGridPlacement = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
columnCount: number,
|
||||
): TPlacement => {
|
||||
const normalizedColumnCount = toPositiveInteger(columnCount, "columnCount");
|
||||
const width = Math.min(toPositiveInteger(placement.w, "placement.w"), normalizedColumnCount);
|
||||
const x = Math.min(Math.max(0, toNonNegativeInteger(placement.x)), normalizedColumnCount - width);
|
||||
|
||||
return {
|
||||
...placement,
|
||||
x,
|
||||
y: toNonNegativeInteger(placement.y),
|
||||
w: width,
|
||||
h: toPositiveInteger(placement.h, "placement.h"),
|
||||
};
|
||||
};
|
||||
|
||||
const toNonNegativeInteger = (value: number) => Math.max(0, Math.trunc(toFiniteNumber(value, "value")));
|
||||
|
||||
const toPositiveInteger = (value: number, name: string) => {
|
||||
const integer = Math.trunc(toFiniteNumber(value, name));
|
||||
if (integer < 1) throw new RangeError(`${name} must be at least 1`);
|
||||
|
||||
return integer;
|
||||
};
|
||||
|
||||
const toFiniteNumber = (value: number, name: string) => {
|
||||
if (!Number.isFinite(value)) throw new TypeError(`${name} must be finite`);
|
||||
|
||||
return value;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./constants";
|
||||
export * from "./board-lanes";
|
||||
export * from "./geometry";
|
||||
export * from "./reflow";
|
||||
export * from "./semantics";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,207 @@
|
||||
import { COLLAPSED_SECTION_ROW_COUNT } from "./constants";
|
||||
import { normalizeGridPlacement } from "./geometry";
|
||||
import type { GridPlacement } from "./types";
|
||||
|
||||
interface ReflowOptions {
|
||||
columnCount: number;
|
||||
/**
|
||||
* A pinned item keeps its requested x/y while every other item compacts
|
||||
* around it. Use this for the item currently being moved or resized.
|
||||
*/
|
||||
pinnedItemId?: string;
|
||||
}
|
||||
|
||||
interface CollapsedLayoutOptions {
|
||||
columnCount: number;
|
||||
collapsedItemIds: ReadonlySet<string>;
|
||||
collapsedRowCount?: number;
|
||||
}
|
||||
|
||||
export const doGridPlacementsOverlap = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.x < second.x + second.w &&
|
||||
first.x + first.w > second.x &&
|
||||
first.y < second.y + second.h &&
|
||||
first.y + first.h > second.y;
|
||||
|
||||
/**
|
||||
* Resolves overlaps and vertically compacts items. Ordering is based on the
|
||||
* persisted position and stable id, so the result does not depend on React or
|
||||
* database array order.
|
||||
*/
|
||||
export const reflowVerticalLayout = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
{ columnCount, pinnedItemId }: ReflowOptions,
|
||||
): TPlacement[] => {
|
||||
assertUniqueIds(placements);
|
||||
|
||||
const normalized = placements.map((placement) => normalizeGridPlacement(placement, columnCount));
|
||||
const pinned = pinnedItemId ? normalized.find((placement) => placement.id === pinnedItemId) : undefined;
|
||||
|
||||
if (pinnedItemId && !pinned) {
|
||||
throw new Error(`Pinned grid item "${pinnedItemId}" does not exist`);
|
||||
}
|
||||
|
||||
const placed: TPlacement[] = pinned ? [pinned] : [];
|
||||
const candidates = normalized.filter((placement) => placement.id !== pinnedItemId).toSorted(comparePlacements);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
placed.push({
|
||||
...candidate,
|
||||
y: findFirstAvailableRow(candidate, placed),
|
||||
});
|
||||
}
|
||||
|
||||
const byId = new Map(placed.map((placement) => [placement.id, placement]));
|
||||
return normalized.map((placement) => getRequiredPlacement(byId, placement.id));
|
||||
};
|
||||
|
||||
/**
|
||||
* Produces the view-only layout after inline containers are collapsed. Only
|
||||
* display geometry changes; callers retain the persisted expanded height.
|
||||
*/
|
||||
export const getCollapsedDisplayLayout = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
{ columnCount, collapsedItemIds, collapsedRowCount = COLLAPSED_SECTION_ROW_COUNT }: CollapsedLayoutOptions,
|
||||
): TPlacement[] => {
|
||||
if (!Number.isFinite(collapsedRowCount) || collapsedRowCount <= 0) {
|
||||
throw new RangeError("collapsedRowCount must be positive and finite");
|
||||
}
|
||||
|
||||
const normalized = placements.map((placement) => normalizeGridPlacement(placement, columnCount));
|
||||
const displayPlacements = normalized.map((placement) =>
|
||||
collapsedItemIds.has(placement.id)
|
||||
? {
|
||||
...placement,
|
||||
h: collapsedRowCount,
|
||||
}
|
||||
: placement,
|
||||
);
|
||||
|
||||
return reflowCollapsedDisplayLayout(normalized, displayPlacements, collapsedItemIds, collapsedRowCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies the requested placement and pins it while neighbors compact into
|
||||
* free space or move down. This is the deterministic fallback used by
|
||||
* keyboard/numeric editing and can also validate a drag library result.
|
||||
*/
|
||||
export const placeGridItem = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
nextPlacement: TPlacement,
|
||||
columnCount: number,
|
||||
) => {
|
||||
if (!placements.some((placement) => placement.id === nextPlacement.id)) {
|
||||
throw new Error(`Grid item "${nextPlacement.id}" does not exist`);
|
||||
}
|
||||
|
||||
return reflowVerticalLayout(
|
||||
placements.map((placement) => (placement.id === nextPlacement.id ? nextPlacement : placement)),
|
||||
{
|
||||
columnCount,
|
||||
pinnedItemId: nextPlacement.id,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const sortGridPlacementsForReadingOrder = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
) => placements.toSorted(comparePlacements);
|
||||
|
||||
const findFirstAvailableRow = (candidate: GridPlacement, placed: readonly GridPlacement[]) => {
|
||||
let y = 0;
|
||||
for (;;) {
|
||||
const atRow = { ...candidate, y };
|
||||
const collisions = placed.filter((placement) => doGridPlacementsOverlap(atRow, placement));
|
||||
if (collisions.length === 0) return y;
|
||||
|
||||
y = Math.max(...collisions.map((placement) => placement.y + placement.h));
|
||||
}
|
||||
};
|
||||
|
||||
const reflowCollapsedDisplayLayout = <TPlacement extends GridPlacement>(
|
||||
expandedPlacements: readonly TPlacement[],
|
||||
displayPlacements: readonly TPlacement[],
|
||||
collapsedItemIds: ReadonlySet<string>,
|
||||
collapsedRowCount: number,
|
||||
): TPlacement[] => {
|
||||
assertUniqueIds(expandedPlacements);
|
||||
const displayById = new Map(displayPlacements.map((placement) => [placement.id, placement]));
|
||||
const collapsedPlacements = expandedPlacements.filter((placement) => collapsedItemIds.has(placement.id));
|
||||
const placed: TPlacement[] = [];
|
||||
|
||||
for (const expandedPlacement of expandedPlacements.toSorted(comparePlacements)) {
|
||||
const candidate = getRequiredPlacement(displayById, expandedPlacement.id);
|
||||
const verticalShift = getCollapsedVerticalShift(expandedPlacement, collapsedPlacements, collapsedRowCount);
|
||||
placed.push({
|
||||
...candidate,
|
||||
y: findFirstAvailableRowAtOrAfter(candidate, placed, Math.max(0, candidate.y - verticalShift)),
|
||||
});
|
||||
}
|
||||
|
||||
const byId = new Map(placed.map((placement) => [placement.id, placement]));
|
||||
return displayPlacements.map((placement) => getRequiredPlacement(byId, placement.id));
|
||||
};
|
||||
|
||||
const getCollapsedVerticalShift = (
|
||||
candidate: GridPlacement,
|
||||
collapsedPlacements: readonly GridPlacement[],
|
||||
collapsedRowCount: number,
|
||||
) => {
|
||||
const removedIntervals = collapsedPlacements
|
||||
.filter(
|
||||
(collapsed) =>
|
||||
collapsed.id !== candidate.id &&
|
||||
collapsed.y + collapsed.h <= candidate.y &&
|
||||
doGridPlacementsOverlapHorizontally(candidate, collapsed),
|
||||
)
|
||||
.map((collapsed) => ({ start: collapsed.y + collapsedRowCount, end: collapsed.y + collapsed.h }))
|
||||
.toSorted((first, second) => first.start - second.start || first.end - second.end);
|
||||
|
||||
let shift = 0;
|
||||
let intervalEnd = Number.NEGATIVE_INFINITY;
|
||||
for (const interval of removedIntervals) {
|
||||
const start = Math.max(interval.start, intervalEnd);
|
||||
if (interval.end > start) shift += interval.end - start;
|
||||
intervalEnd = Math.max(intervalEnd, interval.end);
|
||||
}
|
||||
return shift;
|
||||
};
|
||||
|
||||
const doGridPlacementsOverlapHorizontally = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.x < second.x + second.w && first.x + first.w > second.x;
|
||||
|
||||
const findFirstAvailableRowAtOrAfter = (
|
||||
candidate: GridPlacement,
|
||||
placed: readonly GridPlacement[],
|
||||
minimumRow: number,
|
||||
) => {
|
||||
let y = minimumRow;
|
||||
for (;;) {
|
||||
const atRow = { ...candidate, y };
|
||||
const collisions = placed.filter((placement) => doGridPlacementsOverlap(atRow, placement));
|
||||
if (collisions.length === 0) return y;
|
||||
|
||||
y = Math.max(...collisions.map((placement) => placement.y + placement.h));
|
||||
}
|
||||
};
|
||||
|
||||
const comparePlacements = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.y - second.y || first.x - second.x || (first.id === second.id ? 0 : first.id < second.id ? -1 : 1);
|
||||
|
||||
const assertUniqueIds = (placements: readonly GridPlacement[]) => {
|
||||
const ids = new Set<string>();
|
||||
for (const placement of placements) {
|
||||
if (ids.has(placement.id)) throw new Error(`Duplicate grid item id "${placement.id}"`);
|
||||
ids.add(placement.id);
|
||||
}
|
||||
};
|
||||
|
||||
const getRequiredPlacement = <TPlacement extends GridPlacement>(
|
||||
placements: ReadonlyMap<string, TPlacement>,
|
||||
id: string,
|
||||
) => {
|
||||
const placement = placements.get(id);
|
||||
if (!placement) throw new Error(`Grid item "${id}" was lost during layout`);
|
||||
|
||||
return placement;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
.viewport {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
}
|
||||
|
||||
.viewport[data-board-hydrated="false"] {
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.sizer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
transform-origin: top left;
|
||||
will-change: transform;
|
||||
|
||||
/*
|
||||
* Keep dashboard UI at its normal readable density while the fixed canvas
|
||||
* is fitted below 100%. Above 100%, the canvas still behaves like browser
|
||||
* zoom and enlarges everything together.
|
||||
*/
|
||||
--mantine-scale: var(--board-canvas-ui-scale, 1);
|
||||
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.canvas {
|
||||
will-change: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import { MIN_ACCESSIBLE_CANVAS_SCALE } from "./constants";
|
||||
import classes from "./scaled-board-canvas.module.css";
|
||||
|
||||
export { MIN_ACCESSIBLE_CANVAS_SCALE };
|
||||
|
||||
const BoardCanvasScaleContext = createContext(1);
|
||||
|
||||
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
|
||||
|
||||
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
|
||||
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
|
||||
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
|
||||
|
||||
return Math.max(MIN_ACCESSIBLE_CANVAS_SCALE, availableWidth / logicalWidth);
|
||||
};
|
||||
|
||||
export const calculateBoardUiScale = (canvasScale: number) => {
|
||||
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
|
||||
|
||||
return canvasScale < 1 ? 1 / canvasScale : 1;
|
||||
};
|
||||
|
||||
interface ScaledBoardCanvasProps {
|
||||
logicalWidth: number;
|
||||
initialLogicalHeight: number;
|
||||
initialAvailableWidth: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scales the complete board as one surface while keeping widget layout in
|
||||
* fixed logical pixels. The scale floor deliberately allows horizontal
|
||||
* overflow so browser zoom still magnifies content instead of being cancelled
|
||||
* by responsive downscaling.
|
||||
*/
|
||||
export const ScaledBoardCanvas = ({
|
||||
logicalWidth,
|
||||
initialLogicalHeight,
|
||||
initialAvailableWidth,
|
||||
label,
|
||||
children,
|
||||
}: PropsWithChildren<ScaledBoardCanvasProps>) => {
|
||||
const { ref: viewportRef, width: availableWidth } = useElementSize<HTMLDivElement>();
|
||||
const { ref: canvasRef, height: logicalHeight } = useElementSize<HTMLDivElement>();
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsHydrated(true);
|
||||
}, []);
|
||||
|
||||
const resolvedAvailableWidth = availableWidth > 0 ? availableWidth : initialAvailableWidth;
|
||||
const resolvedLogicalHeight = logicalHeight > 0 ? logicalHeight : initialLogicalHeight;
|
||||
const scale = useMemo(
|
||||
() => calculateBoardCanvasScale(resolvedAvailableWidth, logicalWidth),
|
||||
[logicalWidth, resolvedAvailableWidth],
|
||||
);
|
||||
const uiScale = calculateBoardUiScale(scale);
|
||||
const visualWidth = logicalWidth * scale;
|
||||
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="section"
|
||||
ref={viewportRef}
|
||||
className={classes.viewport}
|
||||
data-testid="board-canvas"
|
||||
data-board-hydrated={isHydrated ? "true" : "false"}
|
||||
data-canvas-scale={scale}
|
||||
data-canvas-overflow={hasHorizontalOverflow ? "true" : "false"}
|
||||
data-canvas-initial-height={initialLogicalHeight}
|
||||
data-canvas-initial-width={initialAvailableWidth}
|
||||
aria-label={label}
|
||||
>
|
||||
<Box className={classes.sizer} w={visualWidth} h={resolvedLogicalHeight * scale}>
|
||||
<Box
|
||||
ref={canvasRef}
|
||||
className={classes.canvas}
|
||||
w={logicalWidth}
|
||||
style={{
|
||||
"--board-canvas-ui-scale": uiScale,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { GridPlacement } from "./types";
|
||||
|
||||
interface ReadonlyCanvasAttributesInput {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface EditableCanvasAttributesInput extends ReadonlyCanvasAttributesInput {
|
||||
columnCount: number;
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
interface EditableGridCellAttributesInput {
|
||||
label: string;
|
||||
placement: GridPlacement;
|
||||
}
|
||||
|
||||
/**
|
||||
* View mode is a labelled region, not an ARIA grid. It keeps the native
|
||||
* semantics and focus behavior of links, buttons and widget controls inside.
|
||||
*/
|
||||
export const getReadonlyCanvasAttributes = ({ label }: ReadonlyCanvasAttributesInput) =>
|
||||
({
|
||||
role: "region",
|
||||
"aria-label": label,
|
||||
"data-grid-readonly": "true",
|
||||
}) as const;
|
||||
|
||||
export const getReadonlyGridItemAttributes = (placement: GridPlacement) =>
|
||||
({
|
||||
"data-grid-id": placement.id,
|
||||
"data-grid-x": placement.x,
|
||||
"data-grid-y": placement.y,
|
||||
"data-grid-w": placement.w,
|
||||
"data-grid-h": placement.h,
|
||||
}) as const;
|
||||
|
||||
/**
|
||||
* Edit mode remains a labelled region. Dashboard placement is not an ARIA
|
||||
* data grid: arrow keys edit geometry rather than navigate rows and columns.
|
||||
* Each tile is one focusable direct-manipulation surface, while controls inside
|
||||
* it keep their native semantics and tab order.
|
||||
*/
|
||||
export const getEditableCanvasAttributes = ({ label }: EditableCanvasAttributesInput) =>
|
||||
({
|
||||
role: "region",
|
||||
"aria-label": label,
|
||||
"data-grid-editable": "true",
|
||||
}) as const;
|
||||
|
||||
export const getEditableGridCellAttributes = ({ label, placement }: EditableGridCellAttributesInput) =>
|
||||
({
|
||||
role: "group",
|
||||
"aria-label": label,
|
||||
tabIndex: 0,
|
||||
...getReadonlyGridItemAttributes(placement),
|
||||
}) as const;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-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 { getBoardLaneColumnCount, getInitialBoardLogicalHeight, getRootSectionForLane } from "../index";
|
||||
|
||||
describe("board lanes", () => {
|
||||
test("gutters consume columns from the main canvas", () => {
|
||||
const layout = {
|
||||
id: "layout",
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 2,
|
||||
rightGutterColumnCount: 3,
|
||||
breakpoint: 0,
|
||||
} satisfies Board["layouts"][number];
|
||||
|
||||
expect(getBoardLaneColumnCount(layout, "left")).toBe(2);
|
||||
expect(getBoardLaneColumnCount(layout, "main")).toBe(7);
|
||||
expect(getBoardLaneColumnCount(layout, "right")).toBe(3);
|
||||
});
|
||||
|
||||
test("clamps invalid gutters while preserving one main column", () => {
|
||||
const layout = {
|
||||
id: "layout",
|
||||
name: "Base",
|
||||
columnCount: 4,
|
||||
leftGutterColumnCount: 3,
|
||||
rightGutterColumnCount: 3,
|
||||
breakpoint: 0,
|
||||
} satisfies Board["layouts"][number];
|
||||
|
||||
expect(getBoardLaneColumnCount(layout, "left")).toBe(3);
|
||||
expect(getBoardLaneColumnCount(layout, "main")).toBe(1);
|
||||
expect(getBoardLaneColumnCount(layout, "right")).toBe(0);
|
||||
});
|
||||
|
||||
test("rejects ambiguous duplicate roots", () => {
|
||||
const board = new BoardMockBuilder()
|
||||
.addSection(new EmptySectionMockBuilder({ id: "main-a" }).build())
|
||||
.addSection(new EmptySectionMockBuilder({ id: "main-b" }).build())
|
||||
.build();
|
||||
|
||||
expect(() => getRootSectionForLane(board, "main")).toThrow("multiple main canvas roots");
|
||||
});
|
||||
|
||||
test("calculates a stable server height from the tallest visible lane", () => {
|
||||
const layoutId = "layout";
|
||||
const mainSectionId = "main";
|
||||
const leftSectionId = "left";
|
||||
const containerSectionId = "container";
|
||||
const board = new BoardMockBuilder()
|
||||
.addSection(new EmptySectionMockBuilder({ id: mainSectionId }).build())
|
||||
.addSection(new EmptySectionMockBuilder({ id: leftSectionId, xOffset: -1 }).build())
|
||||
.addSection(
|
||||
new ContainerSectionMockBuilder({
|
||||
id: containerSectionId,
|
||||
collapsed: true,
|
||||
options: { collapsible: true },
|
||||
})
|
||||
.addLayout({
|
||||
layoutId,
|
||||
parentSectionId: mainSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 3,
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder()
|
||||
.addLayout({
|
||||
layoutId,
|
||||
sectionId: mainSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 3,
|
||||
width: 1,
|
||||
height: 1,
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder()
|
||||
.addLayout({
|
||||
layoutId,
|
||||
sectionId: leftSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 4,
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
board.layouts = [
|
||||
{
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
columnCount: 8,
|
||||
leftGutterColumnCount: 1,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
},
|
||||
];
|
||||
|
||||
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(872);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize, normalizeGridPlacement } from "../index";
|
||||
|
||||
describe("fixed dashboard geometry", () => {
|
||||
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
|
||||
expect(getLogicalTrackSize(1)).toBe(200);
|
||||
expect(getLogicalTrackSize(0.5)).toBe(88);
|
||||
expect(getLogicalTrackSize(2)).toBe(424);
|
||||
expect(getLogicalTrackSize(3)).toBe(648);
|
||||
|
||||
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
|
||||
position: "absolute",
|
||||
left: 224,
|
||||
top: 448,
|
||||
width: 424,
|
||||
height: 648,
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes invalid bounds and derives the required row count", () => {
|
||||
const placement = normalizeGridPlacement({ id: "wide", x: 7, y: -2, w: 8, h: 2 }, 4);
|
||||
|
||||
expect(placement).toEqual({ id: "wide", x: 0, y: 0, w: 4, h: 2 });
|
||||
expect(getLayoutRowCount([placement, { x: 0, y: 4, w: 1, h: 3 }])).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
doGridPlacementsOverlap,
|
||||
getCollapsedDisplayLayout,
|
||||
placeGridItem,
|
||||
reflowVerticalLayout,
|
||||
sortGridPlacementsForReadingOrder,
|
||||
} from "../index";
|
||||
import type { GridPlacement } from "../index";
|
||||
|
||||
describe("dashboard layout reflow", () => {
|
||||
test("compacts each horizontal lane and removes collisions", () => {
|
||||
const result = reflowVerticalLayout(
|
||||
[
|
||||
{ id: "a", x: 0, y: 0, w: 1, h: 1 },
|
||||
{ id: "b", x: 0, y: 3, w: 1, h: 1 },
|
||||
{ id: "c", x: 1, y: 3, w: 1, h: 1 },
|
||||
],
|
||||
{ columnCount: 2 },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "a", x: 0, y: 0, w: 1, h: 1 },
|
||||
{ id: "b", x: 0, y: 1, w: 1, h: 1 },
|
||||
{ id: "c", x: 1, y: 0, w: 1, h: 1 },
|
||||
]);
|
||||
expect(hasOverlap(result)).toBe(false);
|
||||
});
|
||||
|
||||
test("shrinks a collapsed inline container without compacting unrelated columns", () => {
|
||||
const result = getCollapsedDisplayLayout(
|
||||
[
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
|
||||
{ id: "below", x: 0, y: 3, w: 2, h: 1 },
|
||||
{ id: "other-lane", x: 2, y: 4, w: 1, h: 1 },
|
||||
],
|
||||
{
|
||||
columnCount: 3,
|
||||
collapsedItemIds: new Set(["section"]),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 0.5 },
|
||||
{ id: "below", x: 0, y: 0.5, w: 2, h: 1 },
|
||||
{ id: "other-lane", x: 2, y: 4, w: 1, h: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves intentional gaps below a collapsed container", () => {
|
||||
const result = getCollapsedDisplayLayout(
|
||||
[
|
||||
{ id: "section", x: 0, y: 1, w: 2, h: 3 },
|
||||
{ id: "below", x: 0, y: 6, w: 2, h: 1 },
|
||||
{ id: "above", x: 0, y: 0, w: 2, h: 1 },
|
||||
],
|
||||
{ columnCount: 2, collapsedItemIds: new Set(["section"]) },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "section", x: 0, y: 1, w: 2, h: 0.5 },
|
||||
{ id: "below", x: 0, y: 3.5, w: 2, h: 1 },
|
||||
{ id: "above", x: 0, y: 0, w: 2, h: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not double-count side-by-side collapsed space", () => {
|
||||
const result = getCollapsedDisplayLayout(
|
||||
[
|
||||
{ id: "left", x: 0, y: 0, w: 1, h: 3 },
|
||||
{ id: "right", x: 1, y: 0, w: 1, h: 3 },
|
||||
{ id: "below", x: 0, y: 3, w: 2, h: 1 },
|
||||
],
|
||||
{ columnCount: 2, collapsedItemIds: new Set(["left", "right"]) },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "left", x: 0, y: 0, w: 1, h: 0.5 },
|
||||
{ id: "right", x: 1, y: 0, w: 1, h: 0.5 },
|
||||
{ id: "below", x: 0, y: 0.5, w: 2, h: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("pins a moved or expanded item and pushes collisions out of the way", () => {
|
||||
const result = placeGridItem(
|
||||
[
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 1 },
|
||||
{ id: "neighbor", x: 0, y: 1, w: 2, h: 1 },
|
||||
{ id: "side", x: 2, y: 2, w: 1, h: 1 },
|
||||
],
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
|
||||
3,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
|
||||
{ id: "neighbor", x: 0, y: 3, w: 2, h: 1 },
|
||||
{ id: "side", x: 2, y: 0, w: 1, h: 1 },
|
||||
]);
|
||||
expect(hasOverlap(result)).toBe(false);
|
||||
});
|
||||
|
||||
test("is deterministic regardless of input array order", () => {
|
||||
const first = [
|
||||
{ id: "b", x: 0, y: 2, w: 1, h: 1 },
|
||||
{ id: "a", x: 0, y: 2, w: 1, h: 1 },
|
||||
{ id: "c", x: 1, y: 8, w: 1, h: 1 },
|
||||
];
|
||||
const second = first.toReversed();
|
||||
|
||||
expect(byId(reflowVerticalLayout(first, { columnCount: 2 }))).toEqual(
|
||||
byId(reflowVerticalLayout(second, { columnCount: 2 })),
|
||||
);
|
||||
});
|
||||
|
||||
test("provides row-major DOM order for the read-only renderer", () => {
|
||||
const items = [
|
||||
{ id: "last", x: 0, y: 2, w: 1, h: 1 },
|
||||
{ id: "right", x: 1, y: 0, w: 1, h: 1 },
|
||||
{ id: "left", x: 0, y: 0, w: 1, h: 1 },
|
||||
];
|
||||
|
||||
expect(sortGridPlacementsForReadingOrder(items).map((item) => item.id)).toEqual(["left", "right", "last"]);
|
||||
});
|
||||
});
|
||||
|
||||
const hasOverlap = (placements: readonly GridPlacement[]) =>
|
||||
placements.some((placement, index) =>
|
||||
placements.slice(index + 1).some((otherPlacement) => doGridPlacementsOverlap(placement, otherPlacement)),
|
||||
);
|
||||
|
||||
const byId = (placements: readonly GridPlacement[]) =>
|
||||
Object.fromEntries(placements.map((placement) => [placement.id, placement]));
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { calculateBoardCanvasScale, calculateBoardUiScale, MIN_ACCESSIBLE_CANVAS_SCALE } from "../scaled-board-canvas";
|
||||
|
||||
describe("board canvas scale policy", () => {
|
||||
test("fits the logical canvas exactly when the viewport can preserve the minimum scale", () => {
|
||||
const scale = calculateBoardCanvasScale(840, 1000);
|
||||
|
||||
expect(scale).toBe(0.84);
|
||||
expect(1000 * scale).toBe(840);
|
||||
});
|
||||
|
||||
test("scales up uniformly on a larger screen", () => {
|
||||
expect(calculateBoardCanvasScale(1800, 1000)).toBe(1.8);
|
||||
});
|
||||
|
||||
test("stops counter-scaling at 0.75 and lets the canvas overflow", () => {
|
||||
const availableWidth = 600;
|
||||
const logicalWidth = 1000;
|
||||
const scale = calculateBoardCanvasScale(availableWidth, logicalWidth);
|
||||
|
||||
expect(scale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
|
||||
expect(logicalWidth * scale).toBeGreaterThan(availableWidth);
|
||||
});
|
||||
|
||||
test("keeps content materially magnified at 200% effective zoom", () => {
|
||||
const logicalWidth = 1696;
|
||||
const normalAvailableWidth = 1872;
|
||||
const normalScale = calculateBoardCanvasScale(normalAvailableWidth, logicalWidth);
|
||||
const zoomedScale = calculateBoardCanvasScale(normalAvailableWidth / 2, logicalWidth);
|
||||
const normalPaintedCellSize = 200 * normalScale;
|
||||
const zoomedPaintedCellSize = 200 * zoomedScale * 2;
|
||||
|
||||
expect(zoomedScale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
|
||||
expect(zoomedPaintedCellSize).toBeGreaterThan(normalPaintedCellSize * 1.25);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[0, 1000],
|
||||
[1000, 0],
|
||||
[Number.NaN, 1000],
|
||||
[1000, Number.POSITIVE_INFINITY],
|
||||
])("uses a stable initial scale for unavailable geometry (%s, %s)", (availableWidth, logicalWidth) => {
|
||||
expect(calculateBoardCanvasScale(availableWidth, logicalWidth)).toBe(1);
|
||||
});
|
||||
|
||||
test("keeps UI typography and density at 100% while fitting the canvas down", () => {
|
||||
expect(calculateBoardUiScale(0.75)).toBeCloseTo(4 / 3);
|
||||
expect(calculateBoardUiScale(0.84)).toBeCloseTo(1 / 0.84);
|
||||
expect(calculateBoardUiScale(1)).toBe(1);
|
||||
expect(calculateBoardUiScale(1.5)).toBe(1);
|
||||
});
|
||||
|
||||
test.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
|
||||
"uses stable UI density for an invalid canvas scale (%s)",
|
||||
(scale) => {
|
||||
expect(calculateBoardUiScale(scale)).toBe(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
getEditableCanvasAttributes,
|
||||
getEditableGridCellAttributes,
|
||||
getReadonlyCanvasAttributes,
|
||||
getReadonlyGridItemAttributes,
|
||||
} from "../index";
|
||||
|
||||
describe("dashboard layout semantics", () => {
|
||||
const placement = { id: "weather", x: 1, y: 2, w: 3, h: 2 };
|
||||
|
||||
test("keeps read-only mode a native labelled region", () => {
|
||||
expect(getReadonlyCanvasAttributes({ label: "Home dashboard" })).toEqual({
|
||||
role: "region",
|
||||
"aria-label": "Home dashboard",
|
||||
"data-grid-readonly": "true",
|
||||
});
|
||||
expect(getReadonlyGridItemAttributes(placement)).toEqual({
|
||||
"data-grid-id": "weather",
|
||||
"data-grid-x": 1,
|
||||
"data-grid-y": 2,
|
||||
"data-grid-w": 3,
|
||||
"data-grid-h": 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps edit mode a labelled region with spatial item metadata", () => {
|
||||
expect(
|
||||
getEditableCanvasAttributes({
|
||||
label: "Edit home dashboard",
|
||||
columnCount: 12,
|
||||
rowCount: 8,
|
||||
}),
|
||||
).toEqual({
|
||||
role: "region",
|
||||
"aria-label": "Edit home dashboard",
|
||||
"data-grid-editable": "true",
|
||||
});
|
||||
|
||||
expect(
|
||||
getEditableGridCellAttributes({
|
||||
label: "Weather widget",
|
||||
placement,
|
||||
}),
|
||||
).toEqual({
|
||||
role: "group",
|
||||
"aria-label": "Weather widget",
|
||||
tabIndex: 0,
|
||||
"data-grid-id": "weather",
|
||||
"data-grid-x": 1,
|
||||
"data-grid-y": 2,
|
||||
"data-grid-w": 3,
|
||||
"data-grid-h": 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface GridCoordinates {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface GridPlacement extends GridCoordinates {
|
||||
id: string;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { CategoryMenu } from "./category/category-menu";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
const localStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`;
|
||||
|
||||
interface Props {
|
||||
section: CategorySection;
|
||||
}
|
||||
|
||||
export const BoardCategorySection = ({ section }: Props) => {
|
||||
const { status } = useSession();
|
||||
const { mutate } = clientApi.section.changeCollapsed.useMutation();
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const initialCollapsed =
|
||||
typeof window !== "undefined"
|
||||
? (localStorage.getItem(localStorageKey(section.id)) ?? String(section.collapsed)) === "true"
|
||||
: section.collapsed;
|
||||
|
||||
const [opened, { toggle }] = useDisclosure(initialCollapsed, {
|
||||
onOpen() {
|
||||
if (status === "authenticated") {
|
||||
mutate({ sectionId: section.id, collapsed: true });
|
||||
} else if (status === "unauthenticated") {
|
||||
localStorage.setItem(localStorageKey(section.id), "true");
|
||||
}
|
||||
},
|
||||
onClose() {
|
||||
if (status === "authenticated") {
|
||||
mutate({ sectionId: section.id, collapsed: false });
|
||||
} else if (status === "unauthenticated") {
|
||||
localStorage.setItem(localStorageKey(section.id), "false");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card style={{ "--opacity": board.opacity / 100 }} radius={board.itemRadius} p={0} className={classes.itemCard}>
|
||||
<Stack>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<UnstyledButton w="100%" p="sm" onClick={toggle}>
|
||||
<Group wrap="nowrap">
|
||||
{opened ? <IconChevronUp size={20} /> : <IconChevronDown size={20} />}
|
||||
<Title order={3}>{section.name}</Title>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<CategoryMenu category={section} />
|
||||
</Group>
|
||||
<Collapse expanded={opened} p="sm" pt={0}>
|
||||
<GridStack section={section} />
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface MoveCategoryInput {
|
||||
id: string;
|
||||
direction: "up" | "down";
|
||||
}
|
||||
|
||||
export const moveCategoryCallback =
|
||||
(input: MoveCategoryInput) =>
|
||||
(previous: RouterOutputs["board"]["getHomeBoard"]): RouterOutputs["board"]["getHomeBoard"] => {
|
||||
const currentCategory = previous.sections.find(
|
||||
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
|
||||
);
|
||||
if (!currentCategory) {
|
||||
return previous;
|
||||
}
|
||||
if (currentCategory.yOffset === 1 && input.direction === "up") {
|
||||
return previous;
|
||||
}
|
||||
if (currentCategory.yOffset === previous.sections.length - 2 && input.direction === "down") {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
if (section.kind !== "category" && section.kind !== "empty") {
|
||||
return section;
|
||||
}
|
||||
const offset = input.direction === "up" ? -2 : 2;
|
||||
// Move category and empty section
|
||||
if (section.yOffset === currentCategory.yOffset || section.yOffset === currentCategory.yOffset + 1) {
|
||||
return {
|
||||
...section,
|
||||
yOffset: section.yOffset + offset,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
section.yOffset === currentCategory.yOffset + offset ||
|
||||
section.yOffset === currentCategory.yOffset + offset + 1
|
||||
) {
|
||||
return {
|
||||
...section,
|
||||
yOffset: section.yOffset - offset,
|
||||
};
|
||||
}
|
||||
|
||||
return section;
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -1,150 +0,0 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
|
||||
import type { Board, CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface RemoveCategoryInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const removeCategoryCallback =
|
||||
(input: RemoveCategoryInput) =>
|
||||
(previous: Board): Board => {
|
||||
const currentCategory = previous.sections.find(
|
||||
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
|
||||
);
|
||||
if (!currentCategory) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const emptySectionsAbove = previous.sections.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && section.yOffset < currentCategory.yOffset,
|
||||
);
|
||||
const aboveSection = emptySectionsAbove.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset).at(0);
|
||||
|
||||
const emptySectionsBelow = previous.sections.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && section.yOffset > currentCategory.yOffset,
|
||||
);
|
||||
const removedSection = emptySectionsBelow.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset).at(0);
|
||||
|
||||
if (!aboveSection || !removedSection) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
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 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.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;
|
||||
|
||||
if (layout.parentSectionId === currentCategory.id) {
|
||||
return {
|
||||
...layout,
|
||||
yOffset: layout.yOffset + aboveYOffset,
|
||||
parentSectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
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 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;
|
||||
}, 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);
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
import { moveCategoryCallback } from "../move-category";
|
||||
|
||||
describe("Move Category", () => {
|
||||
test.each([
|
||||
[3, [0, 3, 4, 1, 2, 5, 6]],
|
||||
[5, [0, 1, 2, 5, 6, 3, 4]],
|
||||
])("should move category up", (moveId, expectedOrder) => {
|
||||
const sections = createSections(3);
|
||||
|
||||
const input = {
|
||||
id: moveId.toString(),
|
||||
direction: "up" as const,
|
||||
};
|
||||
|
||||
const result = moveCategoryCallback(input)({ sections } as never);
|
||||
|
||||
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
|
||||
});
|
||||
test.each([
|
||||
[1, [0, 3, 4, 1, 2, 5, 6]],
|
||||
[3, [0, 1, 2, 5, 6, 3, 4]],
|
||||
])("should move category down", (moveId, expectedOrder) => {
|
||||
const sections = createSections(3);
|
||||
|
||||
const input = {
|
||||
id: moveId.toString(),
|
||||
direction: "down" as const,
|
||||
};
|
||||
|
||||
const result = moveCategoryCallback(input)({ sections } as never);
|
||||
|
||||
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
|
||||
});
|
||||
test("should not move category up if it is at the top", () => {
|
||||
const sections = createSections(3);
|
||||
|
||||
const input = {
|
||||
id: "1",
|
||||
direction: "up" as const,
|
||||
};
|
||||
|
||||
const result = moveCategoryCallback(input)({ sections } as never);
|
||||
|
||||
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
test("should not move category down if it is at the bottom", () => {
|
||||
const sections = createSections(3);
|
||||
|
||||
const input = {
|
||||
id: "5",
|
||||
direction: "down" as const,
|
||||
};
|
||||
|
||||
const result = moveCategoryCallback(input)({ sections } as never);
|
||||
|
||||
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
const createSections = (categoryCount: number) => {
|
||||
return Array.from({ length: categoryCount * 2 + 1 }, (_, index) => ({
|
||||
id: index.toString(),
|
||||
kind: index % 2 === 1 ? ("category" as const) : ("empty" as const),
|
||||
name: `Category ${index}`,
|
||||
yOffset: index,
|
||||
xOffset: 0,
|
||||
collapsed: false,
|
||||
items: [],
|
||||
})) satisfies Section[];
|
||||
};
|
||||
|
||||
const sortSections = (sections: Section[]) => {
|
||||
return sections
|
||||
.filter((section) => section.kind !== "dynamic")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
};
|
||||
-145
@@ -1,145 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
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", () => {
|
||||
test.each([
|
||||
[3, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 5, 6], [3, 4], 2],
|
||||
[5, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 3, 4], [5, 6], 4],
|
||||
[1, [0, 1, 2, 3, 4, 5, 6], [0, 3, 4, 5, 6], [1, 2], 0],
|
||||
[3, [0, 3, 6, 7, 8], [0, 7, 8], [3, 6], 0],
|
||||
])(
|
||||
"should remove category",
|
||||
(removeId, initialYOffsets, expectedYOffsets, expectedRemovals, expectedLocationOfItems) => {
|
||||
// Arrange
|
||||
const layoutId = "1";
|
||||
const input = removeId.toString();
|
||||
|
||||
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 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 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();
|
||||
|
||||
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 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.layouts.at(0)?.yOffset).toBe(12);
|
||||
expect(dynamicSection.layouts[0]?.parentSectionId).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
const createSections = (initialYOffsets: number[]) => {
|
||||
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(),
|
||||
);
|
||||
};
|
||||
@@ -1,145 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
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";
|
||||
import { removeCategoryCallback } from "./actions/remove-category";
|
||||
|
||||
interface AddCategory {
|
||||
name: string;
|
||||
yOffset: number;
|
||||
}
|
||||
|
||||
interface RenameCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const useCategoryActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const addCategory = useCallback(
|
||||
({ name, yOffset }: AddCategory) => {
|
||||
if (yOffset <= -1) {
|
||||
return;
|
||||
}
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: [
|
||||
// Place sections before the new category
|
||||
...previous.sections.filter(
|
||||
(section) => (section.kind === "category" || section.kind === "empty") && section.yOffset < yOffset,
|
||||
),
|
||||
{
|
||||
id: createId(),
|
||||
name,
|
||||
kind: "category",
|
||||
yOffset,
|
||||
xOffset: 0,
|
||||
collapsed: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
yOffset: yOffset + 1,
|
||||
xOffset: 0,
|
||||
},
|
||||
// Place sections after the new category
|
||||
...previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
(section.kind === "category" || section.kind === "empty") && section.yOffset >= yOffset,
|
||||
)
|
||||
.map((section) => ({
|
||||
...section,
|
||||
yOffset: section.yOffset + 2,
|
||||
})),
|
||||
...previous.sections.filter((section) => section.kind !== "category" && section.kind !== "empty"),
|
||||
] satisfies Section[],
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const addCategoryToEnd = useCallback(
|
||||
({ name }: { name: string }) => {
|
||||
updateBoard((previous) => {
|
||||
const lastSection = previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
section.kind === "empty" || section.kind === "category",
|
||||
)
|
||||
.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!lastSection) return previous;
|
||||
const lastYOffset = lastSection.yOffset;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: [
|
||||
...previous.sections,
|
||||
{
|
||||
id: createId(),
|
||||
name,
|
||||
kind: "category",
|
||||
yOffset: lastYOffset + 1,
|
||||
xOffset: 0,
|
||||
collapsed: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
yOffset: lastYOffset + 2,
|
||||
xOffset: 0,
|
||||
},
|
||||
] satisfies Section[],
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const renameCategory = useCallback(
|
||||
({ id: categoryId, name }: RenameCategory) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
if (section.kind !== "category") return section;
|
||||
if (section.id !== categoryId) return section;
|
||||
return {
|
||||
...section,
|
||||
name,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const moveCategory = useCallback(
|
||||
(input: MoveCategoryInput) => {
|
||||
updateBoard(moveCategoryCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeCategory = useCallback(
|
||||
(input: RemoveCategoryInput) => {
|
||||
updateBoard(removeCategoryCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return {
|
||||
addCategory,
|
||||
addCategoryToEnd,
|
||||
renameCategory,
|
||||
moveCategory,
|
||||
removeCategory,
|
||||
};
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Stack, TextInput } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface InnerProps {
|
||||
submitLabel: string;
|
||||
category: Category;
|
||||
onSuccess: (category: Category) => void;
|
||||
}
|
||||
|
||||
export const CategoryEditModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const form = useZodForm(z.object({ name: z.string().min(1) }), {
|
||||
initialValues: {
|
||||
name: innerProps.category.name,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
void innerProps.onSuccess({
|
||||
...innerProps.category,
|
||||
name: values.name,
|
||||
});
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label={t("section.category.field.name.label")} data-autofocus {...form.getInputProps("name")} />
|
||||
<ModalFormFooter onCancel={actions.closeModal} submitLabel={innerProps.submitLabel} />
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createId, getSafeApplicationUrl } from "@homarr/common";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { useCategoryActions } from "./category-actions";
|
||||
import { CategoryEditModal } from "./category-edit-modal";
|
||||
import { filterByItemKind } from "./filter";
|
||||
|
||||
export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
const { openModal } = useModalAction(CategoryEditModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
|
||||
const t = useI18n();
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const createCategoryAtYOffset = useCallback(
|
||||
(position: number) => {
|
||||
openModal(
|
||||
{
|
||||
category: {
|
||||
id: createId(),
|
||||
name: t("section.category.create.title"),
|
||||
},
|
||||
onSuccess: (category) => {
|
||||
addCategory({
|
||||
name: category.name,
|
||||
yOffset: position,
|
||||
});
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
{
|
||||
title: (t) => t("section.category.create.title"),
|
||||
},
|
||||
);
|
||||
},
|
||||
[addCategory, t, openModal],
|
||||
);
|
||||
|
||||
// creates a new category above the current
|
||||
const addCategoryAbove = useCallback(() => {
|
||||
const aboveYOffset = category.yOffset;
|
||||
createCategoryAtYOffset(aboveYOffset);
|
||||
}, [category.yOffset, createCategoryAtYOffset]);
|
||||
|
||||
// creates a new category below the current
|
||||
const addCategoryBelow = useCallback(() => {
|
||||
const belowYOffset = category.yOffset + 2;
|
||||
createCategoryAtYOffset(belowYOffset);
|
||||
}, [category.yOffset, createCategoryAtYOffset]);
|
||||
|
||||
const moveCategoryUp = useCallback(() => {
|
||||
moveCategory({
|
||||
id: category.id,
|
||||
direction: "up",
|
||||
});
|
||||
}, [category.id, moveCategory]);
|
||||
|
||||
const moveCategoryDown = useCallback(() => {
|
||||
moveCategory({
|
||||
id: category.id,
|
||||
direction: "down",
|
||||
});
|
||||
}, [category.id, moveCategory]);
|
||||
|
||||
// Removes the current category
|
||||
const remove = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t("section.category.remove.title"),
|
||||
children: t("section.category.remove.message", {
|
||||
name: category.name,
|
||||
}),
|
||||
onConfirm: () => {
|
||||
removeCategory({
|
||||
id: category.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [category.id, category.name, removeCategory, t, openConfirmModal]);
|
||||
|
||||
const edit = useCallback(() => {
|
||||
openModal(
|
||||
{
|
||||
category,
|
||||
submitLabel: t("section.category.edit.submit"),
|
||||
onSuccess: (category) => {
|
||||
renameCategory({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (t) => t("section.category.edit.title"),
|
||||
},
|
||||
);
|
||||
}, [category, openModal, renameCategory, t]);
|
||||
|
||||
const settings = useSettings();
|
||||
const openAllInNewTabs = useCallback(async () => {
|
||||
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;
|
||||
});
|
||||
|
||||
const apps = await fetchApi.app.byIds.query(appIds);
|
||||
const safeUrls = apps.flatMap((app) => {
|
||||
const href = getSafeApplicationUrl(app.href);
|
||||
return href ? [href] : [];
|
||||
});
|
||||
|
||||
for (const href of safeUrls) {
|
||||
const openedWindow = window.open(href, "_blank", "noopener,noreferrer");
|
||||
if (openedWindow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
openConfirmModal({
|
||||
title: t("section.category.openAllInNewTabs.title"),
|
||||
children: t("section.category.openAllInNewTabs.text"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, [category, board, t, openConfirmModal, settings]);
|
||||
|
||||
return {
|
||||
addCategoryAbove,
|
||||
addCategoryBelow,
|
||||
moveCategoryUp,
|
||||
moveCategoryDown,
|
||||
remove,
|
||||
edit,
|
||||
openAllInNewTabs,
|
||||
};
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import {
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconExternalLink,
|
||||
IconRowInsertBottom,
|
||||
IconRowInsertTop,
|
||||
IconTransitionBottom,
|
||||
IconTransitionTop,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { useCategoryMenuActions } from "./category-menu-actions";
|
||||
|
||||
interface Props {
|
||||
category: CategorySection;
|
||||
}
|
||||
|
||||
export const CategoryMenu = ({ category }: Props) => {
|
||||
const actions = useActions(category);
|
||||
const t = useScopedI18n("section.category");
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon mr="sm" variant="transparent" aria-label={t("menu.label.settings")}>
|
||||
<IconDotsVertical size={20} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{actions.map((action) => {
|
||||
return (
|
||||
<React.Fragment key={action.label}>
|
||||
{"group" in action && <Menu.Label>{t(action.group)}</Menu.Label>}
|
||||
<Menu.Item
|
||||
leftSection={<action.icon size="1rem" />}
|
||||
onClick={action.onClick}
|
||||
color={"color" in action ? action.color : undefined}
|
||||
>
|
||||
{t(action.label)}
|
||||
</Menu.Item>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const useActions = (category: CategorySection) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const editModeActions = useEditModeActions(category);
|
||||
const nonEditModeActions = useNonEditModeActions(category);
|
||||
|
||||
return useMemo(
|
||||
() => (isEditMode ? editModeActions : nonEditModeActions),
|
||||
[isEditMode, editModeActions, nonEditModeActions],
|
||||
);
|
||||
};
|
||||
|
||||
const useEditModeActions = (category: CategorySection) => {
|
||||
const { addCategoryAbove, addCategoryBelow, moveCategoryUp, moveCategoryDown, edit, remove } =
|
||||
useCategoryMenuActions(category);
|
||||
|
||||
return [
|
||||
{
|
||||
icon: IconEdit,
|
||||
label: "action.edit",
|
||||
onClick: edit,
|
||||
},
|
||||
{
|
||||
icon: IconTrash,
|
||||
color: "red",
|
||||
label: "action.remove",
|
||||
onClick: remove,
|
||||
},
|
||||
{
|
||||
group: "menu.label.changePosition",
|
||||
icon: IconTransitionTop,
|
||||
label: "action.moveUp",
|
||||
onClick: moveCategoryUp,
|
||||
},
|
||||
{
|
||||
icon: IconTransitionBottom,
|
||||
label: "action.moveDown",
|
||||
onClick: moveCategoryDown,
|
||||
},
|
||||
{
|
||||
group: "menu.label.create",
|
||||
icon: IconRowInsertTop,
|
||||
label: "action.createAbove",
|
||||
onClick: addCategoryAbove,
|
||||
},
|
||||
{
|
||||
icon: IconRowInsertBottom,
|
||||
label: "action.createBelow",
|
||||
onClick: addCategoryBelow,
|
||||
},
|
||||
] as const satisfies ActionDefinition[];
|
||||
};
|
||||
|
||||
const useNonEditModeActions = (category: CategorySection) => {
|
||||
const { openAllInNewTabs } = useCategoryMenuActions(category);
|
||||
return [
|
||||
{
|
||||
icon: IconExternalLink,
|
||||
label: "action.openAllInNewTabs",
|
||||
onClick: openAllInNewTabs,
|
||||
},
|
||||
] as const satisfies ActionDefinition[];
|
||||
};
|
||||
|
||||
interface ActionDefinition {
|
||||
icon: TablerIcon;
|
||||
label: string;
|
||||
onClick: () => MaybePromise<void>;
|
||||
color?: string;
|
||||
group?: string;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const filterByItemKind = <TKind extends WidgetKind>(
|
||||
items: Item[],
|
||||
settings: SettingsContextProps,
|
||||
kind: TKind,
|
||||
) => {
|
||||
return items
|
||||
.filter((item) => item.kind === kind)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
options: reduceWidgetOptionsWithDefaultValues(
|
||||
kind,
|
||||
settings,
|
||||
item.options,
|
||||
) as WidgetComponentProps<TKind>["options"],
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { ActionIcon, Badge, Box, Button, Card } from "@mantine/core";
|
||||
import { IconChevronDown, IconChevronUp, IconExternalLink, IconGripVertical } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { SectionGrid } from "./grid/section-grid";
|
||||
import { useSectionCollapse } from "./section-collapse";
|
||||
import { useOpenSectionApps } from "./use-open-section-apps";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
const BoardContainerMenu = dynamic(
|
||||
() => import("./container/container-menu").then((module) => module.BoardContainerMenu),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface Props {
|
||||
section: ContainerSectionItem;
|
||||
}
|
||||
|
||||
export const BoardContainerSection = ({ section }: Props) => {
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const t = useScopedI18n("section.container");
|
||||
const tAll = useI18n();
|
||||
const options = section.options;
|
||||
const { open: openAllInNewTabs, isLoading: areAppsLoading } = useOpenSectionApps(
|
||||
section.id,
|
||||
options.showOpenAll && !isEditMode,
|
||||
);
|
||||
const { isVisuallyCollapsed, toggle } = useSectionCollapse({
|
||||
sectionId: section.id,
|
||||
collapsible: options.collapsible,
|
||||
});
|
||||
const label = options.title.trim() || t("untitled");
|
||||
const contentId = `board-container-${section.id}-content`;
|
||||
const labelLeft = isEditMode ? 40 : 8;
|
||||
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
|
||||
|
||||
return (
|
||||
<Box className="board-grid-item-content" data-grid-item-content w="100%" h="100%" style={{ overflow: "visible" }}>
|
||||
<Card
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
options.customCssClasses.join(" "),
|
||||
isVisuallyCollapsed && classes.collapsedContainerCard,
|
||||
)}
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-board-container-collapsed={isVisuallyCollapsed ? "true" : "false"}
|
||||
styles={{
|
||||
root: {
|
||||
overflow: "visible",
|
||||
"--opacity": board.opacity / 100,
|
||||
"--border-color": options.borderColor || undefined,
|
||||
},
|
||||
}}
|
||||
radius={board.itemRadius}
|
||||
p={0}
|
||||
>
|
||||
{options.collapsible && !isEditMode && (
|
||||
<Button
|
||||
className={classes.containerToggle}
|
||||
pos="absolute"
|
||||
top={isVisuallyCollapsed ? 0 : -24}
|
||||
left={0}
|
||||
w="100%"
|
||||
h={isVisuallyCollapsed ? "100%" : 24}
|
||||
px={12}
|
||||
pe={options.showOpenAll ? 40 : 12}
|
||||
radius="sm"
|
||||
variant="default"
|
||||
justify={options.showLabel ? "flex-start" : "center"}
|
||||
leftSection={
|
||||
options.showLabel ? (
|
||||
isVisuallyCollapsed ? (
|
||||
<IconChevronDown size={16} />
|
||||
) : (
|
||||
<IconChevronUp size={16} />
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
onClick={toggle}
|
||||
aria-expanded={!isVisuallyCollapsed}
|
||||
aria-controls={contentId}
|
||||
aria-label={`${t(isVisuallyCollapsed ? "action.expand" : "action.collapse")}: ${label}`}
|
||||
data-board-container-collapsed-control={isVisuallyCollapsed ? "true" : undefined}
|
||||
data-board-container-label
|
||||
title={options.showLabel ? label : undefined}
|
||||
>
|
||||
{options.showLabel ? (
|
||||
label
|
||||
) : isVisuallyCollapsed ? (
|
||||
<IconChevronDown size={16} />
|
||||
) : (
|
||||
<IconChevronUp size={16} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{isEditMode && (
|
||||
<ActionIcon
|
||||
component="span"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
left={4}
|
||||
style={{ zIndex: 10, cursor: "grab", touchAction: "none" }}
|
||||
variant="default"
|
||||
size={24}
|
||||
radius="sm"
|
||||
data-grid-container-drag-handle
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IconGripVertical size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!isVisuallyCollapsed && (!options.collapsible || isEditMode) && options.showLabel && options.title && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
top={-24}
|
||||
left={labelLeft}
|
||||
maw={`calc(100% - ${labelLeft + labelRight}px)`}
|
||||
size="md"
|
||||
radius="sm"
|
||||
variant="default"
|
||||
c="var(--mantine-color-text)"
|
||||
style={{
|
||||
zIndex: 9,
|
||||
overflow: "hidden",
|
||||
pointerEvents: "none",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
backgroundColor: "var(--background-color)",
|
||||
borderColor: "var(--border-color)",
|
||||
}}
|
||||
title={options.title}
|
||||
data-board-container-label
|
||||
>
|
||||
{options.title}
|
||||
</Badge>
|
||||
)}
|
||||
{options.showOpenAll && !isEditMode && (
|
||||
<ActionIcon
|
||||
pos="absolute"
|
||||
top={isVisuallyCollapsed ? "50%" : -24}
|
||||
right={8}
|
||||
style={{ zIndex: 10, transform: isVisuallyCollapsed ? "translateY(-50%)" : undefined }}
|
||||
variant={options.collapsible ? "subtle" : "default"}
|
||||
size={24}
|
||||
radius="sm"
|
||||
loading={areAppsLoading}
|
||||
onClick={openAllInNewTabs}
|
||||
aria-label={tAll("section.action.openAllInNewTabsFor", { name: label })}
|
||||
>
|
||||
<IconExternalLink size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Box
|
||||
id={contentId}
|
||||
className={classes.containerBody}
|
||||
h="100%"
|
||||
data-collapsed={isVisuallyCollapsed ? "true" : "false"}
|
||||
aria-hidden={isVisuallyCollapsed}
|
||||
inert={isVisuallyCollapsed}
|
||||
>
|
||||
<SectionGrid section={section} columnCount={section.width} requestedRowCount={section.height} label={label} />
|
||||
</Box>
|
||||
</Card>
|
||||
{isEditMode && <BoardContainerMenu section={section} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import type { Board, ContainerSection, ContainerSectionLayout, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
|
||||
import { getSectionElements } from "~/components/board/items/actions/section-elements";
|
||||
|
||||
export const addContainerCallback = () => (board: Board) => {
|
||||
const firstSection = board.sections
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) return board;
|
||||
|
||||
const sectionLayouts = createContainerLayouts(board, firstSection);
|
||||
const newSection = {
|
||||
id: createId(),
|
||||
kind: "container",
|
||||
collapsed: false,
|
||||
options: containerSectionOptionsSchema.parse(undefined),
|
||||
layouts: sectionLayouts,
|
||||
} satisfies ContainerSection;
|
||||
|
||||
return {
|
||||
...board,
|
||||
sections: board.sections.concat(newSection),
|
||||
};
|
||||
};
|
||||
|
||||
const createContainerLayouts = (board: Board, currentSection: EmptySection): ContainerSectionLayout[] => {
|
||||
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 columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : 1;
|
||||
const size = {
|
||||
width: Math.min(4, columnCount),
|
||||
height: 3,
|
||||
};
|
||||
|
||||
const emptyPosition = boardLayout
|
||||
? getFirstEmptyPosition(elements, columnCount, undefined, size)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) throw new Error("Your board is full");
|
||||
|
||||
return {
|
||||
...size,
|
||||
...emptyPosition,
|
||||
parentSectionId: currentSection.id,
|
||||
layoutId,
|
||||
};
|
||||
});
|
||||
};
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import type { ContainerSection } from "~/app/[locale]/boards/_types";
|
||||
import * as emptyPositionModule from "~/components/board/items/actions/empty-position";
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { addContainerCallback } from "./add-container";
|
||||
import { removeContainerCallback } from "./remove-container";
|
||||
|
||||
describe("container actions", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("creates a container in the first main root for every responsive layout", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "root", yOffset: 0 }).build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 2;
|
||||
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layout.id]);
|
||||
vi.spyOn(emptyPositionModule, "getFirstEmptyPosition").mockReturnValue({ xOffset: 0, yOffset: 4 });
|
||||
|
||||
const result = addContainerCallback()(board);
|
||||
const created = result.sections.find((section) => section.kind === "container");
|
||||
|
||||
expect(created).toMatchObject({
|
||||
kind: "container",
|
||||
collapsed: false,
|
||||
options: {
|
||||
showLabel: true,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
},
|
||||
layouts: [
|
||||
{
|
||||
layoutId: "layout",
|
||||
parentSectionId: "root",
|
||||
xOffset: 0,
|
||||
yOffset: 4,
|
||||
width: 2,
|
||||
height: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("removes a container and flattens its direct children into the parent", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "root", xOffset: 3, yOffset: 4, width: 6, height: 5 })
|
||||
.build();
|
||||
const child = new ContainerSectionMockBuilder({ id: "child" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2, width: 2, height: 2 })
|
||||
.build();
|
||||
const nestedItem = new ItemMockBuilder({ id: "nested-item" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1, width: 1, height: 1 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addEmptySection({ id: "root" })
|
||||
.addSection(removed)
|
||||
.addSection(child)
|
||||
.addItem(nestedItem)
|
||||
.build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
const flattenedChild = result.sections.find(
|
||||
(section): section is ContainerSection => section.kind === "container" && section.id === child.id,
|
||||
);
|
||||
|
||||
expect(result.sections.some((section) => section.id === removed.id)).toBe(false);
|
||||
expect(flattenedChild?.layouts[0]).toMatchObject({
|
||||
parentSectionId: "root",
|
||||
xOffset: 4,
|
||||
yOffset: 6,
|
||||
});
|
||||
expect(result.items.find((item) => item.id === nestedItem.id)?.layouts[0]).toMatchObject({
|
||||
sectionId: "root",
|
||||
xOffset: 5,
|
||||
yOffset: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes, pins the top-left child, and resolves collisions while flattening malformed geometry", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 2 })
|
||||
.build();
|
||||
const existing = new ItemMockBuilder({ id: "existing" })
|
||||
.addLayout({ layoutId: "layout", sectionId: "root", xOffset: 0, yOffset: 0, width: 2, height: 1 })
|
||||
.build();
|
||||
const childA = new ItemMockBuilder({ id: "a" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 1, yOffset: 0, width: 2, height: 1 })
|
||||
.build();
|
||||
const childB = new ItemMockBuilder({ id: "b" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 0, yOffset: 0, width: 2, height: 1 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addEmptySection({ id: "root" })
|
||||
.addSection(removed)
|
||||
.addItems([existing, childA, childB])
|
||||
.build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 4;
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
const layouts = Object.fromEntries(
|
||||
result.items.map((item) => {
|
||||
const itemLayout = item.layouts[0];
|
||||
if (!itemLayout) throw new Error("Expected an item layout");
|
||||
return [item.id, itemLayout];
|
||||
}),
|
||||
);
|
||||
|
||||
expect(layouts.existing).toMatchObject({ xOffset: 0, yOffset: 0, width: 2, height: 1 });
|
||||
expect(layouts.a).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 1, width: 2, height: 1 });
|
||||
expect(layouts.b).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 1 });
|
||||
});
|
||||
|
||||
test("keeps the container when a child container has layout geometry that cannot be flattened", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "other-layout", parentSectionId: "root", xOffset: 3, yOffset: 4 })
|
||||
.build();
|
||||
const child = new ContainerSectionMockBuilder({ id: "child" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addSection(child).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
|
||||
expect(result).toBe(board);
|
||||
expect(result.sections).toContainEqual(removed);
|
||||
});
|
||||
|
||||
test("keeps the container when a child item has layout geometry that cannot be flattened", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "other-layout", parentSectionId: "root", xOffset: 3, yOffset: 4 })
|
||||
.build();
|
||||
const item = new ItemMockBuilder({ id: "item" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addItem(item).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
|
||||
expect(result).toBe(board);
|
||||
expect(result.sections).toContainEqual(removed);
|
||||
});
|
||||
|
||||
test("keeps the container when flattening would move a child back into the removed container", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "removed", xOffset: 3, yOffset: 4 })
|
||||
.build();
|
||||
const item = new ItemMockBuilder({ id: "item" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addItem(item).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
|
||||
expect(result).toBe(board);
|
||||
expect(result.sections).toContainEqual(removed);
|
||||
});
|
||||
|
||||
test("keeps the container when its destination is a child container", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "child", xOffset: 3, yOffset: 4 })
|
||||
.build();
|
||||
const child = new ContainerSectionMockBuilder({ id: "child" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addSection(child).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
|
||||
expect(result).toBe(board);
|
||||
expect(result.sections).toContainEqual(removed);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Board, ContainerSection } from "~/app/[locale]/boards/_types";
|
||||
import { resolvePinnedGridCollisions } from "~/components/board/sections/grid/dnd";
|
||||
import {
|
||||
getSectionGridColumnCount,
|
||||
getSectionGridPlacements,
|
||||
} from "~/components/board/sections/grid/section-grid-placements";
|
||||
import type { SectionGridPlacement } from "~/components/board/sections/grid/use-grid-layout-actions";
|
||||
|
||||
export interface RemoveContainerInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const removeContainerCallback =
|
||||
({ id }: RemoveContainerInput) =>
|
||||
(board: Board): Board => {
|
||||
const sectionToRemove = board.sections.find(
|
||||
(section): section is ContainerSection => section.id === id && section.kind === "container",
|
||||
);
|
||||
if (!sectionToRemove) return board;
|
||||
|
||||
const removedLayoutsById = new Map(sectionToRemove.layouts.map((layout) => [layout.layoutId, layout]));
|
||||
const childLayoutIds = [
|
||||
...board.sections.flatMap((section) =>
|
||||
section.kind === "container"
|
||||
? section.layouts.flatMap((layout) =>
|
||||
layout.parentSectionId === sectionToRemove.id ? [layout.layoutId] : [],
|
||||
)
|
||||
: [],
|
||||
),
|
||||
...board.items.flatMap((item) =>
|
||||
item.layouts.flatMap((layout) => (layout.sectionId === sectionToRemove.id ? [layout.layoutId] : [])),
|
||||
),
|
||||
];
|
||||
const hasUnresolvableChildLayout = childLayoutIds.some((layoutId) => {
|
||||
const removedLayout = removedLayoutsById.get(layoutId);
|
||||
return (
|
||||
!removedLayout || !isValidFlattenDestination(board, sectionToRemove.id, layoutId, removedLayout.parentSectionId)
|
||||
);
|
||||
});
|
||||
if (hasUnresolvableChildLayout) return board;
|
||||
|
||||
const flattened = {
|
||||
...board,
|
||||
sections: board.sections
|
||||
.filter((section) => section.id !== id)
|
||||
.map((section) => {
|
||||
if (section.kind !== "container") return 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) return layout;
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
parentSectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
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) return layout;
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
sectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
return resolveFlattenedLayouts(flattened, sectionToRemove, board);
|
||||
};
|
||||
|
||||
const isValidFlattenDestination = (
|
||||
board: Board,
|
||||
removedSectionId: string,
|
||||
layoutId: string,
|
||||
destinationSectionId: string,
|
||||
) => {
|
||||
const visited = new Set<string>();
|
||||
let sectionId = destinationSectionId;
|
||||
|
||||
while (true) {
|
||||
if (sectionId === removedSectionId || visited.has(sectionId)) return false;
|
||||
visited.add(sectionId);
|
||||
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) return false;
|
||||
if (section.kind !== "container") return true;
|
||||
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) return false;
|
||||
sectionId = layout.parentSectionId;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveFlattenedLayouts = (board: Board, removed: ContainerSection, original: Board): Board => {
|
||||
const resolvedByLayoutAndId = new Map<string, SectionGridPlacement>();
|
||||
|
||||
for (const removedLayout of removed.layouts) {
|
||||
const movedIds = [
|
||||
...original.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === removedLayout.layoutId);
|
||||
return layout?.sectionId === removed.id ? [item.id] : [];
|
||||
}),
|
||||
...original.sections.flatMap((section) => {
|
||||
if (section.kind !== "container") return [];
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === removedLayout.layoutId);
|
||||
return layout?.parentSectionId === removed.id ? [section.id] : [];
|
||||
}),
|
||||
];
|
||||
const columnCount = getSectionGridColumnCount(board, removedLayout.layoutId, removedLayout.parentSectionId);
|
||||
if (!columnCount) continue;
|
||||
const placements = getSectionGridPlacements(board, removedLayout.layoutId, removedLayout.parentSectionId);
|
||||
const movedIdSet = new Set(movedIds);
|
||||
const pinned = placements
|
||||
.filter((placement) => movedIdSet.has(placement.id))
|
||||
.toSorted((first, second) => first.y - second.y || first.x - second.x || first.id.localeCompare(second.id))[0];
|
||||
if (!pinned) continue;
|
||||
|
||||
for (const placement of resolvePinnedGridCollisions(placements, pinned, columnCount)) {
|
||||
resolvedByLayoutAndId.set(`${removedLayout.layoutId}:${placement.id}`, placement);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedByLayoutAndId.size === 0) return board;
|
||||
return {
|
||||
...board,
|
||||
items: board.items.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => {
|
||||
const placement = resolvedByLayoutAndId.get(`${layout.layoutId}:${item.id}`);
|
||||
return placement?.type === "item"
|
||||
? {
|
||||
...layout,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
}
|
||||
: layout;
|
||||
}),
|
||||
})),
|
||||
sections: board.sections.map((section) =>
|
||||
section.kind !== "container"
|
||||
? section
|
||||
: {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
const placement = resolvedByLayoutAndId.get(`${layout.layoutId}:${section.id}`);
|
||||
return placement?.type === "section"
|
||||
? {
|
||||
...layout,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
}
|
||||
: layout;
|
||||
}),
|
||||
},
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { ContainerSectionOptions } from "@homarr/validation/shared";
|
||||
|
||||
import { addContainerCallback } from "./actions/add-container";
|
||||
import type { RemoveContainerInput } from "./actions/remove-container";
|
||||
import { removeContainerCallback } from "./actions/remove-container";
|
||||
|
||||
interface UpdateContainerOptions {
|
||||
containerId: string;
|
||||
newOptions: ContainerSectionOptions;
|
||||
}
|
||||
|
||||
export const useContainerActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const addContainer = useCallback(() => {
|
||||
updateBoard(addContainerCallback());
|
||||
}, [updateBoard]);
|
||||
|
||||
const updateContainer = useCallback(
|
||||
({ containerId, newOptions }: UpdateContainerOptions) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((item) =>
|
||||
item.id !== containerId || item.kind !== "container" ? item : { ...item, options: newOptions },
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeContainer = useCallback(
|
||||
(input: RemoveContainerInput) => {
|
||||
updateBoard(removeContainerCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return { addContainer, updateContainer, removeContainer };
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { CloseButton, ColorInput, Stack, Switch, TextInput, useMantineTheme } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { TextMultiSelect } from "@homarr/ui";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
interface ModalProps {
|
||||
value: z.infer<typeof containerSectionOptionsSchema>;
|
||||
onSuccessfulEdit: (value: z.infer<typeof containerSectionOptionsSchema>) => void;
|
||||
}
|
||||
|
||||
export const ContainerEditModal = createModal<ModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const theme = useMantineTheme();
|
||||
const form = useZodForm(containerSectionOptionsSchema.unwrap(), {
|
||||
mode: "controlled",
|
||||
initialValues: { ...innerProps.value },
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
innerProps.onSuccessfulEdit(values);
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label={t("section.container.option.title.label")} data-autofocus {...form.getInputProps("title")} />
|
||||
<Switch
|
||||
label={t("section.option.showLabel.label")}
|
||||
description={t("section.option.showLabel.description")}
|
||||
{...form.getInputProps("showLabel", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("section.option.collapsible.label")}
|
||||
description={t("section.option.collapsible.description")}
|
||||
{...form.getInputProps("collapsible", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("section.option.showOpenAll.label")}
|
||||
description={t("section.option.showOpenAll.description")}
|
||||
{...form.getInputProps("showOpenAll", { type: "checkbox" })}
|
||||
/>
|
||||
<TextMultiSelect
|
||||
label={t("section.container.option.customCssClasses.label")}
|
||||
{...form.getInputProps("customCssClasses")}
|
||||
/>
|
||||
<ColorInput
|
||||
label={t("section.container.option.borderColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
rightSection={
|
||||
<CloseButton
|
||||
onClick={() => form.setFieldValue("borderColor", "")}
|
||||
style={{ display: form.getInputProps("borderColor").value ? undefined : "none" }}
|
||||
/>
|
||||
}
|
||||
{...form.getInputProps("borderColor")}
|
||||
/>
|
||||
<ModalFormFooter onCancel={actions.closeModal} />
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useOpenItemMoveModal } from "../../items/item-move-modal";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { useContainerActions } from "./container-actions";
|
||||
import { ContainerEditModal } from "./container-edit-modal";
|
||||
|
||||
export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem }) => {
|
||||
const t = useI18n();
|
||||
const tContainer = useScopedI18n("section.container");
|
||||
const tItem = useScopedI18n("item");
|
||||
const { openModal } = useModalAction(ContainerEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { updateContainer, removeContainer } = useContainerActions();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { section: parentSection } = useSectionContext();
|
||||
const label = section.options.title || tContainer("action.create");
|
||||
const menuRightOffset = parentSection.kind === "container" ? 44 : 4;
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
const openEditModal = () => {
|
||||
openModal({
|
||||
value: section.options,
|
||||
onSuccessfulEdit: (options) => updateContainer({ containerId: section.id, newOptions: options }),
|
||||
});
|
||||
};
|
||||
|
||||
const openRemoveModal = () => {
|
||||
openConfirmModal({
|
||||
title: tContainer("remove.title"),
|
||||
children: tContainer("remove.message"),
|
||||
onConfirm: () => removeContainer({ id: section.id }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={24}
|
||||
radius="sm"
|
||||
pos="absolute"
|
||||
top={-24}
|
||||
right={menuRightOffset}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
<IconLayoutKanban size="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
|
||||
{tItem("action.edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconArrowsMove size={16} />}
|
||||
onClick={() => openMoveModal({ entry: section, sourceSectionId: section.parentSectionId })}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
{tContainer("action.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +1,19 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { GridItemHTMLElement } from "@homarr/gridstack";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
|
||||
import { BoardDynamicSection } from "./dynamic-section";
|
||||
import { GridStackItem } from "./gridstack/gridstack-item";
|
||||
import { BoardContainerSection } from "./container-section";
|
||||
import { FixedGridItem } from "./grid/fixed-grid-item";
|
||||
import { useSectionContext } from "./section-context";
|
||||
import { useSectionItems } from "./use-section-items";
|
||||
|
||||
export const SectionContent = () => {
|
||||
const { innerSections, items, refs } = useSectionContext();
|
||||
const { data: integrations } = clientApi.integration.all.useQuery();
|
||||
const { innerSections, integrations, items } = useSectionContext();
|
||||
|
||||
/**
|
||||
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
|
||||
* THE ITEMS RENDERED WITH GRIDSTACK, OTHERWISE THE ITEMS WILL BE MIXED UP
|
||||
* @see https://github.com/homarr-labs/homarr/pull/1770
|
||||
*/
|
||||
/** Keep portal content order deterministic across static and edit renderers. */
|
||||
const sortedItems = useMemo(() => {
|
||||
return [...items, ...innerSections].toSorted((itemA, itemB) => {
|
||||
if (itemA.yOffset === itemB.yOffset) {
|
||||
@@ -35,57 +27,39 @@ export const SectionContent = () => {
|
||||
return (
|
||||
<>
|
||||
{sortedItems.map((item) => (
|
||||
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
|
||||
<SectionContentItem key={item.id} item={item} integrations={integrations} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemProps {
|
||||
item: DynamicSectionItem | SectionItem;
|
||||
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
|
||||
item: ContainerSectionItem | SectionItem;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
}
|
||||
|
||||
const Item = ({ item, innerRef, integrations }: ItemProps) => {
|
||||
export const SectionContentItem = ({ item, integrations }: 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}
|
||||
>
|
||||
<FixedGridItem key={item.id} item={item} minWidth={minWidth} minHeight={minHeight}>
|
||||
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
|
||||
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
|
||||
</GridStackItem>
|
||||
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardContainerSection section={item} />}
|
||||
</FixedGridItem>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param direction either "x" or "y"
|
||||
* @param items items of the section
|
||||
* @param sections sections of the board to look for dynamic sections
|
||||
* @param parentSectionId the id of the section we want to calculate the min size for
|
||||
* @returns the min size
|
||||
* Calculates a container's minimum width or height from its direct content.
|
||||
*/
|
||||
const useMinSize = (item: DynamicSectionItem | SectionItem, direction: "x" | "y") => {
|
||||
export const useMinSize = (item: ContainerSectionItem | 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]),
|
||||
...innerSections.map((item) => item[`${direction}Offset`] + item[size]),
|
||||
1,
|
||||
...items.map((childItem) => childItem[`${direction}Offset`] + childItem[size]),
|
||||
...innerSections.map((childSection) => childSection[`${direction}Offset`] + childSection[size]),
|
||||
);
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Badge, Box, Card } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
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: DynamicSectionItem;
|
||||
}
|
||||
|
||||
export const BoardDynamicSection = ({ section }: Props) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const options = section.options;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="grid-stack-item-content"
|
||||
style={{
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className={combineClasses(classes.itemCard, section.options.customCssClasses.join(" "))}
|
||||
w="100%"
|
||||
h="100%"
|
||||
styles={{
|
||||
root: {
|
||||
overflow: "visible",
|
||||
"--opacity": board.opacity / 100,
|
||||
"--border-color": options.borderColor || undefined,
|
||||
},
|
||||
}}
|
||||
radius={board.itemRadius}
|
||||
p={0}
|
||||
>
|
||||
{options.title && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
top={-15}
|
||||
left={10}
|
||||
size="md"
|
||||
radius={board.itemRadius}
|
||||
color="var(--background-color)"
|
||||
c="var(--mantine-color-text)"
|
||||
bd="1px solid var(--border-color)"
|
||||
>
|
||||
{options.title}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Use unique key by layout to reinitialize gridstack */}
|
||||
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
|
||||
</Card>
|
||||
<BoardDynamicSectionMenu section={section} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
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",
|
||||
options: {
|
||||
title: "",
|
||||
borderColor: "",
|
||||
customCssClasses: [],
|
||||
},
|
||||
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,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
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,45 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { addDynamicSectionCallback } from "./actions/add-dynamic-section";
|
||||
import type { RemoveDynamicSectionInput } from "./actions/remove-dynamic-section";
|
||||
import { removeDynamicSectionCallback } from "./actions/remove-dynamic-section";
|
||||
|
||||
interface UpdateDynamicOptions {
|
||||
itemId: string;
|
||||
newOptions: z.infer<typeof dynamicSectionOptionsSchema>;
|
||||
}
|
||||
|
||||
export const useDynamicSectionActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const addDynamicSection = useCallback(() => {
|
||||
updateBoard(addDynamicSectionCallback());
|
||||
}, [updateBoard]);
|
||||
|
||||
const updateDynamicSection = useCallback(
|
||||
({ itemId, newOptions }: UpdateDynamicOptions) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((item) => (item.id !== itemId ? item : { ...item, options: newOptions })),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeDynamicSection = useCallback(
|
||||
(input: RemoveDynamicSectionInput) => {
|
||||
updateBoard(removeDynamicSectionCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return {
|
||||
addDynamicSection,
|
||||
updateDynamicSection,
|
||||
removeDynamicSection,
|
||||
};
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CloseButton, ColorInput, Stack, TextInput, useMantineTheme } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { TextMultiSelect } from "@homarr/ui";
|
||||
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
interface ModalProps {
|
||||
value: z.infer<typeof dynamicSectionOptionsSchema>;
|
||||
onSuccessfulEdit: (value: z.infer<typeof dynamicSectionOptionsSchema>) => void;
|
||||
}
|
||||
|
||||
export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const form = useZodForm(dynamicSectionOptionsSchema, {
|
||||
mode: "controlled",
|
||||
initialValues: { ...innerProps.value },
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
innerProps.onSuccessfulEdit(values);
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label={t("section.dynamic.option.title.label")} data-autofocus {...form.getInputProps("title")} />
|
||||
<TextMultiSelect
|
||||
label={t("section.dynamic.option.customCssClasses.label")}
|
||||
{...form.getInputProps("customCssClasses")}
|
||||
/>
|
||||
<ColorInput
|
||||
label={t("section.dynamic.option.borderColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
rightSection={
|
||||
<CloseButton
|
||||
onClick={() => form.setFieldValue("borderColor", "")}
|
||||
style={{ display: form.getInputProps("borderColor").value ? undefined : "none" }}
|
||||
/>
|
||||
}
|
||||
{...form.getInputProps("borderColor")}
|
||||
/>
|
||||
<ModalFormFooter onCancel={actions.closeModal} />
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconDotsVertical, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useDynamicSectionActions } from "./dynamic-actions";
|
||||
import { DynamicSectionEditModal } from "./dynamic-edit-modal";
|
||||
|
||||
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionItem }) => {
|
||||
const t = useI18n();
|
||||
const tDynamic = useScopedI18n("section.dynamic");
|
||||
const tItem = useScopedI18n("item");
|
||||
const { openModal } = useModalAction(DynamicSectionEditModal);
|
||||
const { updateDynamicSection, removeDynamicSection } = useDynamicSectionActions();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
const openEditModal = () => {
|
||||
openModal({
|
||||
value: section.options,
|
||||
onSuccessfulEdit: (options) => {
|
||||
updateDynamicSection({
|
||||
itemId: section.id,
|
||||
newOptions: options,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openRemoveModal = () => {
|
||||
openConfirmModal({
|
||||
title: tDynamic("remove.title"),
|
||||
children: tDynamic("remove.message"),
|
||||
onConfirm: () => {
|
||||
removeDynamicSection({ id: section.id });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius={"xl"}
|
||||
pos="absolute"
|
||||
top={4}
|
||||
right={4}
|
||||
style={{ zIndex: "var(--mantine-z-index-popover)" }}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
|
||||
{tItem("action.edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
{tDynamic("action.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +1,32 @@
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import { SectionGrid } from "./grid/section-grid";
|
||||
import { useSectionItems } from "./use-section-items";
|
||||
|
||||
interface Props {
|
||||
section: EmptySection;
|
||||
columnCount: number;
|
||||
requestedRowCount: number;
|
||||
railPlacement?: "main" | "left" | "right";
|
||||
}
|
||||
|
||||
export const BoardEmptySection = ({ section }: Props) => {
|
||||
export const BoardEmptySection = ({ section, columnCount, requestedRowCount, railPlacement = "main" }: Props) => {
|
||||
const { items, innerSections } = useSectionItems(section.id);
|
||||
const totalLength = items.length + innerSections.length;
|
||||
const [isEditMode] = useEditMode();
|
||||
const t = useScopedI18n("board.landmark");
|
||||
|
||||
if (totalLength === 0 && !isEditMode && requestedRowCount === 0) return null;
|
||||
|
||||
return (
|
||||
<GridStack
|
||||
<SectionGrid
|
||||
section={section}
|
||||
style={{ transitionDuration: "0s" }}
|
||||
className={combineClasses("min-row", totalLength > 0 || isEditMode ? undefined : "grid-stack-empty-wrapper")}
|
||||
columnCount={columnCount}
|
||||
requestedRowCount={requestedRowCount}
|
||||
label={t("items")}
|
||||
railPlacement={railPlacement}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType, PropsWithChildren } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
import { loadGridEditorAsync } from "./grid-editor-loader";
|
||||
import { GridEditorRuntimeProvider } from "./grid-editor-runtime";
|
||||
|
||||
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
|
||||
|
||||
/**
|
||||
* Keeps the dnd-kit runtime outside the read-only bundle while guaranteeing
|
||||
* that every root and nested grid shares one drag-and-drop provider.
|
||||
*/
|
||||
export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const [Provider, setProvider] = useState<BoardGridEditorProviderComponent | null>(null);
|
||||
const [hasLoadError, setHasLoadError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode || Provider) return;
|
||||
|
||||
let cancelled = false;
|
||||
setHasLoadError(false);
|
||||
void loadGridEditorAsync()
|
||||
.then((module) => {
|
||||
if (!cancelled) setProvider(() => module.BoardGridEditorProvider);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHasLoadError(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [Provider, isEditMode]);
|
||||
|
||||
if (!isEditMode) {
|
||||
return <GridEditorRuntimeProvider status="idle">{children}</GridEditorRuntimeProvider>;
|
||||
}
|
||||
|
||||
if (hasLoadError) {
|
||||
return <GridEditorRuntimeProvider status="error">{children}</GridEditorRuntimeProvider>;
|
||||
}
|
||||
|
||||
if (!Provider) {
|
||||
return <GridEditorRuntimeProvider status="loading">{children}</GridEditorRuntimeProvider>;
|
||||
}
|
||||
|
||||
return (
|
||||
<GridEditorRuntimeProvider status="ready">
|
||||
<Provider>{children}</Provider>
|
||||
</GridEditorRuntimeProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import {
|
||||
getContinuousGridDelta,
|
||||
getContinuousResizePlacement,
|
||||
getPointerProjectedShape,
|
||||
getSnappedGridCoordinates,
|
||||
getSnappedGridDelta,
|
||||
} from "./coordinates";
|
||||
|
||||
describe("dnd grid coordinates", () => {
|
||||
test.each([0.75, 1, 1.25, 2])("maps transformed drag geometry at %sx scale", (scale) => {
|
||||
const target = { left: 137, top: -42, width: getLogicalTrackSize(8) * scale };
|
||||
const dragShape = {
|
||||
left: target.left + 3 * LOGICAL_GRID_PITCH * scale,
|
||||
top: target.top + 7 * LOGICAL_GRID_PITCH * scale,
|
||||
};
|
||||
|
||||
expect(getSnappedGridCoordinates({ dragShape, target, columnCount: 8 })).toEqual({ x: 3, y: 7 });
|
||||
});
|
||||
|
||||
test("uses the dragged shape origin so a non-center grab retains its pointer offset", () => {
|
||||
const scale = 0.75;
|
||||
const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale };
|
||||
const initialShape = { left: target.left, top: target.top, width: 424 * scale, height: 200 * scale };
|
||||
const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 };
|
||||
|
||||
expect(
|
||||
getSnappedGridCoordinates({
|
||||
dragShape: {
|
||||
left: initialShape.left + pointerDelta.x,
|
||||
top: initialShape.top + pointerDelta.y,
|
||||
},
|
||||
target,
|
||||
columnCount: 5,
|
||||
}),
|
||||
).toEqual({ x: 2, y: 3 });
|
||||
});
|
||||
|
||||
test.each([0.75, 1, 1.25, 2])("projects a pointer drag in visual coordinates at %sx scale", (scale) => {
|
||||
const target = { left: 496, top: -938, width: getLogicalTrackSize(8) * scale };
|
||||
const initialShape = {
|
||||
left: target.left + 4 * LOGICAL_GRID_PITCH * scale,
|
||||
top: target.top + 8 * LOGICAL_GRID_PITCH * scale,
|
||||
width: getLogicalTrackSize(2) * scale,
|
||||
height: getLogicalTrackSize(2) * scale,
|
||||
};
|
||||
const initialPointer = { x: initialShape.left + 134 * scale, y: initialShape.top + 89 * scale };
|
||||
const dragShape = getPointerProjectedShape(
|
||||
{ initialPointer, initialShape },
|
||||
{ x: initialPointer.x, y: initialPointer.y - LOGICAL_GRID_PITCH * scale },
|
||||
);
|
||||
|
||||
expect(dragShape.left).toBe(initialShape.left);
|
||||
expect(dragShape).toMatchObject({ width: initialShape.width, height: initialShape.height });
|
||||
expect(getSnappedGridCoordinates({ dragShape, target, columnCount: 8 })).toEqual({ x: 4, y: 7 });
|
||||
});
|
||||
|
||||
test.each([0.75, 1, 1.25, 2])("snaps resize pointer deltas at %sx scale", (visualScale) => {
|
||||
expect(
|
||||
getSnappedGridDelta({
|
||||
initial: { x: 19, y: 31 },
|
||||
current: {
|
||||
x: 19 - 2 * LOGICAL_GRID_PITCH * visualScale,
|
||||
y: 31 + 4 * LOGICAL_GRID_PITCH * visualScale,
|
||||
},
|
||||
visualScale,
|
||||
}),
|
||||
).toEqual({ x: -2, y: 4 });
|
||||
});
|
||||
|
||||
test("keeps the visual resize continuous between snapped grid breakpoints", () => {
|
||||
const visualScale = 0.75;
|
||||
const delta = getContinuousGridDelta({
|
||||
initial: { x: 10, y: 20 },
|
||||
current: {
|
||||
x: 10 + LOGICAL_GRID_PITCH * visualScale * 0.42,
|
||||
y: 20 + LOGICAL_GRID_PITCH * visualScale * 0.68,
|
||||
},
|
||||
visualScale,
|
||||
});
|
||||
|
||||
expect(delta?.x).toBeCloseTo(0.42);
|
||||
expect(delta?.y).toBeCloseTo(0.68);
|
||||
expect(
|
||||
getSnappedGridDelta({
|
||||
initial: { x: 10, y: 20 },
|
||||
current: {
|
||||
x: 10 + LOGICAL_GRID_PITCH * visualScale * 0.42,
|
||||
y: 20 + LOGICAL_GRID_PITCH * visualScale * 0.68,
|
||||
},
|
||||
visualScale,
|
||||
}),
|
||||
).toEqual({ x: 0, y: 1 });
|
||||
});
|
||||
|
||||
test("projects a continuous resize while anchoring the opposite edges and respecting bounds", () => {
|
||||
const placement = { id: "widget", x: 2, y: 3, w: 3, h: 2 };
|
||||
|
||||
expect(
|
||||
getContinuousResizePlacement({
|
||||
placement,
|
||||
direction: "nw",
|
||||
deltaColumns: 0.35,
|
||||
deltaRows: -0.6,
|
||||
columnCount: 8,
|
||||
maxRowCount: 10,
|
||||
}),
|
||||
).toEqual({ id: "widget", x: 2.35, y: 2.4, w: 2.65, h: 2.6 });
|
||||
expect(
|
||||
getContinuousResizePlacement({
|
||||
placement,
|
||||
direction: "se",
|
||||
deltaColumns: 99,
|
||||
deltaRows: 99,
|
||||
columnCount: 8,
|
||||
maxRowCount: 7,
|
||||
}),
|
||||
).toEqual({ id: "widget", x: 2, y: 3, w: 6, h: 4 });
|
||||
});
|
||||
|
||||
test("uses independently measured visual axes for transformed resize geometry", () => {
|
||||
expect(
|
||||
getSnappedGridDelta({
|
||||
initial: { x: 10, y: 20 },
|
||||
current: { x: 10 + LOGICAL_GRID_PITCH * 1.25, y: 20 - LOGICAL_GRID_PITCH * 1.5 },
|
||||
visualScale: { x: 1.25, y: 1.5 },
|
||||
}),
|
||||
).toEqual({ x: 1, y: -1 });
|
||||
});
|
||||
|
||||
test("rejects non-positive or non-finite scales", () => {
|
||||
expect(
|
||||
getSnappedGridCoordinates({
|
||||
dragShape: { left: 0, top: 0 },
|
||||
target: { left: 0, top: 0, width: 0 },
|
||||
columnCount: 4,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getSnappedGridDelta({ initial: { x: 0, y: 0 }, current: { x: 1, y: 1 }, visualScale: Number.NaN }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import type { GridResizeDirection } from "./types";
|
||||
|
||||
interface RectangleLike {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface ProjectedRectangle extends RectangleLike {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface DragProjectionOrigin {
|
||||
initialPointer: { x: number; y: number };
|
||||
initialShape: ProjectedRectangle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects the dragged shape from the grab point instead of dnd-kit's
|
||||
* feedback element. The latter can live in a CSS-zoomed overlay whose
|
||||
* bounding rectangle is in a different coordinate space from pointer events.
|
||||
*/
|
||||
export const getPointerProjectedShape = (origin: DragProjectionOrigin, currentPointer: { x: number; y: number }) => ({
|
||||
left: origin.initialShape.left + currentPointer.x - origin.initialPointer.x,
|
||||
top: origin.initialShape.top + currentPointer.y - origin.initialPointer.y,
|
||||
width: origin.initialShape.width,
|
||||
height: origin.initialShape.height,
|
||||
});
|
||||
|
||||
export const getSnappedGridCoordinates = ({
|
||||
dragShape,
|
||||
target,
|
||||
columnCount,
|
||||
}: {
|
||||
dragShape: Pick<RectangleLike, "left" | "top">;
|
||||
target: RectangleLike;
|
||||
columnCount: number;
|
||||
}) => {
|
||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||
const visualScale = logicalWidth > 0 ? target.width / logicalWidth : 0;
|
||||
if (!Number.isFinite(visualScale) || visualScale <= 0) return null;
|
||||
|
||||
return {
|
||||
x: Math.round((dragShape.left - target.left) / (LOGICAL_GRID_PITCH * visualScale)),
|
||||
y: Math.round((dragShape.top - target.top) / (LOGICAL_GRID_PITCH * visualScale)),
|
||||
};
|
||||
};
|
||||
|
||||
export const getSnappedGridDelta = ({
|
||||
current,
|
||||
initial,
|
||||
visualScale,
|
||||
}: {
|
||||
current: { x: number; y: number };
|
||||
initial: { x: number; y: number };
|
||||
visualScale: number | { x: number; y: number };
|
||||
}) => {
|
||||
const delta = getContinuousGridDelta({ current, initial, visualScale });
|
||||
if (!delta) return null;
|
||||
return {
|
||||
x: Math.round(delta.x),
|
||||
y: Math.round(delta.y),
|
||||
};
|
||||
};
|
||||
|
||||
export const getContinuousGridDelta = ({
|
||||
current,
|
||||
initial,
|
||||
visualScale,
|
||||
}: {
|
||||
current: { x: number; y: number };
|
||||
initial: { x: number; y: number };
|
||||
visualScale: number | { x: number; y: number };
|
||||
}) => {
|
||||
const scale = typeof visualScale === "number" ? { x: visualScale, y: visualScale } : visualScale;
|
||||
if (!Number.isFinite(scale.x) || scale.x <= 0 || !Number.isFinite(scale.y) || scale.y <= 0) return null;
|
||||
return {
|
||||
x: (current.x - initial.x) / (LOGICAL_GRID_PITCH * scale.x),
|
||||
y: (current.y - initial.y) / (LOGICAL_GRID_PITCH * scale.y),
|
||||
};
|
||||
};
|
||||
|
||||
/** Projects a resize continuously while the committed layout remains snapped. */
|
||||
export const getContinuousResizePlacement = <TPlacement extends GridPlacement>({
|
||||
placement,
|
||||
direction,
|
||||
deltaColumns,
|
||||
deltaRows,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
minWidth = 1,
|
||||
minHeight = 1,
|
||||
}: {
|
||||
placement: TPlacement;
|
||||
direction: GridResizeDirection;
|
||||
deltaColumns: number;
|
||||
deltaRows: number;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
}): TPlacement => {
|
||||
const fixedRight = placement.x + placement.w;
|
||||
const fixedBottom = placement.y + placement.h;
|
||||
let x = placement.x;
|
||||
let y = placement.y;
|
||||
let w = placement.w;
|
||||
let h = placement.h;
|
||||
|
||||
if (direction.includes("w")) {
|
||||
w = clamp(placement.w - deltaColumns, minWidth, fixedRight);
|
||||
x = fixedRight - w;
|
||||
} else if (direction.includes("e")) {
|
||||
w = clamp(placement.w + deltaColumns, minWidth, columnCount - placement.x);
|
||||
}
|
||||
|
||||
if (direction.includes("n")) {
|
||||
h = clamp(placement.h - deltaRows, minHeight, fixedBottom);
|
||||
y = fixedBottom - h;
|
||||
} else if (direction.includes("s")) {
|
||||
h = clamp(
|
||||
placement.h + deltaRows,
|
||||
minHeight,
|
||||
maxRowCount === null ? Number.MAX_SAFE_INTEGER : maxRowCount - placement.y,
|
||||
);
|
||||
}
|
||||
|
||||
return { ...placement, x, y, w, h };
|
||||
};
|
||||
|
||||
const clamp = (value: number, minimum: number, maximum: number) => Math.min(Math.max(value, minimum), maximum);
|
||||
@@ -0,0 +1,757 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { doGridPlacementsOverlap } from "~/components/board/layout";
|
||||
import {
|
||||
beginGridTransaction,
|
||||
cancelGridTransaction,
|
||||
commitGridTransaction,
|
||||
previewGridMove,
|
||||
previewGridResize,
|
||||
} from "./engine";
|
||||
import type {
|
||||
GridPreviewResult,
|
||||
GridResizeDirection,
|
||||
GridTransaction,
|
||||
TransactionalGrid,
|
||||
TransactionalGridState,
|
||||
} from "./types";
|
||||
|
||||
interface TestPlacement {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
kind: "container" | "item";
|
||||
marker?: string;
|
||||
}
|
||||
|
||||
const placement = (
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
w = 1,
|
||||
h = 1,
|
||||
kind: TestPlacement["kind"] = "item",
|
||||
): TestPlacement => ({ id, x, y, w, h, kind, marker: `metadata:${id}` });
|
||||
|
||||
const grid = (
|
||||
id: string,
|
||||
columnCount: number,
|
||||
placements: readonly TestPlacement[],
|
||||
options: Pick<TransactionalGrid<TestPlacement>, "maxRowCount" | "ownerPlacementId" | "parentGridId"> = {},
|
||||
): TransactionalGrid<TestPlacement> => ({ id, columnCount, placements, ...options });
|
||||
|
||||
const state = (...grids: readonly TransactionalGrid<TestPlacement>[]): TransactionalGridState<TestPlacement> => ({
|
||||
grids,
|
||||
});
|
||||
|
||||
const accept = (result: GridPreviewResult<TestPlacement>): GridTransaction<TestPlacement> => {
|
||||
if (!result.accepted) throw new Error(`Expected preview to be accepted, received ${result.rejection.code}`);
|
||||
return result.transaction;
|
||||
};
|
||||
|
||||
const getGrid = (layout: TransactionalGridState<TestPlacement>, id: string) => {
|
||||
const found = layout.grids.find((candidate) => candidate.id === id);
|
||||
if (!found) throw new Error(`Missing test grid ${id}`);
|
||||
return found;
|
||||
};
|
||||
|
||||
const getPlacement = (layout: TransactionalGridState<TestPlacement>, id: string) => {
|
||||
for (const candidateGrid of layout.grids) {
|
||||
const found = candidateGrid.placements.find((candidate) => candidate.id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
throw new Error(`Missing test placement ${id}`);
|
||||
};
|
||||
|
||||
const coordinatesById = (layout: TransactionalGridState<TestPlacement>, gridId: string) =>
|
||||
Object.fromEntries(getGrid(layout, gridId).placements.map(({ id, x, y, w, h }) => [id, { x, y, w, h }]));
|
||||
|
||||
const expectValidState = (layout: TransactionalGridState<TestPlacement>) => {
|
||||
const allIds = new Set<string>();
|
||||
|
||||
for (const candidateGrid of layout.grids) {
|
||||
for (const [index, candidate] of candidateGrid.placements.entries()) {
|
||||
expect(allIds.has(candidate.id), `duplicate placement ${candidate.id}`).toBe(false);
|
||||
allIds.add(candidate.id);
|
||||
expect([candidate.x, candidate.y, candidate.w, candidate.h].every(Number.isInteger)).toBe(true);
|
||||
expect(candidate.x).toBeGreaterThanOrEqual(0);
|
||||
expect(candidate.y).toBeGreaterThanOrEqual(0);
|
||||
expect(candidate.w).toBeGreaterThanOrEqual(1);
|
||||
expect(candidate.h).toBeGreaterThanOrEqual(1);
|
||||
expect(candidate.x + candidate.w).toBeLessThanOrEqual(candidateGrid.columnCount);
|
||||
if (candidateGrid.maxRowCount !== undefined && candidateGrid.maxRowCount !== null) {
|
||||
expect(candidate.y + candidate.h).toBeLessThanOrEqual(candidateGrid.maxRowCount);
|
||||
}
|
||||
|
||||
for (const other of candidateGrid.placements.slice(index + 1)) {
|
||||
expect(
|
||||
doGridPlacementsOverlap(candidate, other),
|
||||
`${candidate.id} overlaps ${other.id} in ${candidateGrid.id}`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidateGrid of layout.grids) {
|
||||
if (!candidateGrid.ownerPlacementId) continue;
|
||||
const parent = getGrid(layout, candidateGrid.parentGridId ?? "");
|
||||
expect(parent.placements.some(({ id }) => id === candidateGrid.ownerPlacementId)).toBe(true);
|
||||
}
|
||||
};
|
||||
|
||||
describe("grid transaction setup", () => {
|
||||
test("captures detached snapshot and preview copies without mutating caller state", () => {
|
||||
const original = state(grid("root", 4, [placement("active", 0, 0), placement("other", 2, 3)]));
|
||||
const transaction = beginGridTransaction(original, { activeId: "active", sourceGridId: "root" });
|
||||
|
||||
expect(transaction.snapshot).toEqual(original);
|
||||
expect(transaction.preview).toEqual(original);
|
||||
expect(transaction.snapshot).not.toBe(original);
|
||||
expect(transaction.snapshot.grids[0]).not.toBe(original.grids[0]);
|
||||
expect(transaction.snapshot.grids[0]?.placements[0]).not.toBe(original.grids[0]?.placements[0]);
|
||||
expect(transaction.preview).not.toBe(transaction.snapshot);
|
||||
});
|
||||
|
||||
test("rejects a missing active item or stale source-grid hint", () => {
|
||||
const original = state(grid("root", 2, [placement("active", 0, 0)]));
|
||||
|
||||
expect(() => beginGridTransaction(original, { activeId: "missing" })).toThrow('Grid item "missing" does not exist');
|
||||
expect(() => beginGridTransaction(original, { activeId: "active", sourceGridId: "stale" })).toThrow(
|
||||
'belongs to grid "root", not "stale"',
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "duplicate grid ids",
|
||||
layout: state(grid("root", 2, [placement("a", 0, 0)]), grid("root", 2, [placement("b", 0, 0)])),
|
||||
message: "Duplicate grid id",
|
||||
},
|
||||
{
|
||||
name: "duplicate placement ids across grids",
|
||||
layout: state(grid("first", 2, [placement("a", 0, 0)]), grid("second", 2, [placement("a", 0, 0)])),
|
||||
message: "Duplicate grid item id",
|
||||
},
|
||||
{
|
||||
name: "overlaps",
|
||||
layout: state(grid("root", 3, [placement("a", 0, 0, 2), placement("b", 1, 0, 2)])),
|
||||
message: "overlap",
|
||||
},
|
||||
{
|
||||
name: "column overflow",
|
||||
layout: state(grid("root", 2, [placement("a", 1, 0, 2)])),
|
||||
message: "exceeds grid",
|
||||
},
|
||||
{
|
||||
name: "row overflow",
|
||||
layout: state(grid("root", 2, [placement("a", 0, 1, 1, 2)], { maxRowCount: 2 })),
|
||||
message: "exceeds grid",
|
||||
},
|
||||
{
|
||||
name: "missing hierarchy owner",
|
||||
layout: state(
|
||||
grid("root", 2, [placement("a", 0, 0)]),
|
||||
grid("child", 2, [], { parentGridId: "root", ownerPlacementId: "missing" }),
|
||||
),
|
||||
message: "Owner item",
|
||||
},
|
||||
{
|
||||
name: "cyclic hierarchy",
|
||||
layout: state(
|
||||
grid("first", 2, [placement("owns-second", 0, 0)], {
|
||||
parentGridId: "second",
|
||||
ownerPlacementId: "owns-first",
|
||||
}),
|
||||
grid("second", 2, [placement("owns-first", 0, 0)], {
|
||||
parentGridId: "first",
|
||||
ownerPlacementId: "owns-second",
|
||||
}),
|
||||
),
|
||||
message: "cycle",
|
||||
},
|
||||
])("rejects invalid initial state: $name", ({ layout, message }) => {
|
||||
expect(() => beginGridTransaction(layout, { activeId: layout.grids[0]?.placements[0]?.id ?? "missing" })).toThrow(
|
||||
message,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("same-grid movement", () => {
|
||||
test("keeps the exact snapshot for a no-op instead of compacting intentional gaps", () => {
|
||||
const original = state(grid("root", 4, [placement("active", 0, 3), placement("other", 2, 5)]));
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
const moved = accept(previewGridMove(transaction, { targetGridId: "root", x: 0, y: 3 }));
|
||||
|
||||
expect(moved.preview).toEqual(original);
|
||||
expect(moved.preview).not.toBe(original);
|
||||
});
|
||||
|
||||
test("swaps an equal-size item occupying the requested exact slot", () => {
|
||||
const original = state(
|
||||
grid("root", 4, [placement("active", 0, 0), placement("target", 1, 0), placement("untouched", 2, 0)]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(moved.preview, "root")).toEqual({
|
||||
active: { x: 1, y: 0, w: 1, h: 1 },
|
||||
target: { x: 0, y: 0, w: 1, h: 1 },
|
||||
untouched: { x: 2, y: 0, w: 1, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("swaps different-size items when both resulting placements remain valid", () => {
|
||||
const original = state(grid("root", 4, [placement("wide", 0, 0, 2), placement("compact", 2, 0)]));
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "wide" }), {
|
||||
targetGridId: "root",
|
||||
x: 2,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(moved.preview, "root")).toEqual({
|
||||
wide: { x: 2, y: 0, w: 2, h: 1 },
|
||||
compact: { x: 0, y: 0, w: 1, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("uses pinned reflow when an exact-slot swap would exceed the grid bounds", () => {
|
||||
const original = state(grid("root", 4, [placement("compact", 3, 0), placement("wide", 0, 0, 2)]));
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "compact" }), {
|
||||
targetGridId: "root",
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(moved.preview, "root")).toEqual({
|
||||
compact: { x: 0, y: 0, w: 1, h: 1 },
|
||||
wide: { x: 0, y: 1, w: 2, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("pushes a different-size collision without compacting unrelated gaps", () => {
|
||||
const original = state(
|
||||
grid("root", 4, [placement("active", 0, 0), placement("wide", 1, 0, 2), placement("lower-left", 0, 1)]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(moved.preview, "root")).toEqual({
|
||||
active: { x: 1, y: 0, w: 1, h: 1 },
|
||||
wide: { x: 1, y: 1, w: 2, h: 1 },
|
||||
"lower-left": { x: 0, y: 1, w: 1, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("swaps laterally as soon as a move pushes into one compatible item", () => {
|
||||
const original = state(grid("root", 5, [placement("active", 0, 0, 2), placement("target", 2, 0, 2)]));
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 2, y: 0, w: 2, h: 1 });
|
||||
expect(getPlacement(moved.preview, "target")).toMatchObject({ x: 0, y: 0, w: 2, h: 1 });
|
||||
});
|
||||
|
||||
test("swaps the pushed item into the vacated slot when moving from right to left", () => {
|
||||
const original = state(grid("root", 5, [placement("target", 0, 0, 2), placement("active", 2, 0, 2)]));
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 0, y: 0, w: 2, h: 1 });
|
||||
expect(getPlacement(moved.preview, "target")).toMatchObject({ x: 2, y: 0, w: 2, h: 1 });
|
||||
});
|
||||
|
||||
test("preserves intentional empty rows outside the collision chain", () => {
|
||||
const original = state(
|
||||
grid("root", 4, [placement("active", 0, 0), placement("collision", 1, 0, 2), placement("far", 3, 8)]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "collision")).toMatchObject({ x: 1, y: 1 });
|
||||
expect(getPlacement(moved.preview, "far")).toMatchObject({ x: 3, y: 8 });
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ requested: { x: -20, y: -10 }, expected: { x: 0, y: 0 } },
|
||||
{ requested: { x: 99, y: 99 }, expected: { x: 2, y: 3 } },
|
||||
{ requested: { x: 2.9, y: 1.9 }, expected: { x: 2, y: 1 } },
|
||||
])("clamps and quantizes requested coordinates: $requested", ({ requested, expected }) => {
|
||||
const original = state(grid("root", 4, [placement("active", 1, 1, 2, 2)], { maxRowCount: 5 }));
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
...requested,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "active")).toMatchObject(expected);
|
||||
});
|
||||
|
||||
test("produces the same result regardless of backing-array order", () => {
|
||||
const placements = [
|
||||
placement("active", 3, 0),
|
||||
placement("left", 0, 2),
|
||||
placement("middle", 1, 2),
|
||||
placement("right", 2, 2),
|
||||
];
|
||||
const forward = state(grid("root", 4, placements));
|
||||
const reversed = state(grid("root", 4, placements.toReversed()));
|
||||
const input = { targetGridId: "root", x: 0, y: 0 };
|
||||
|
||||
const first = accept(previewGridMove(beginGridTransaction(forward, { activeId: "active" }), input));
|
||||
const second = accept(previewGridMove(beginGridTransaction(reversed, { activeId: "active" }), input));
|
||||
|
||||
expect(coordinatesById(first.preview, "root")).toEqual(coordinatesById(second.preview, "root"));
|
||||
});
|
||||
|
||||
test("recomputes every chained preview from the interaction snapshot", () => {
|
||||
const original = state(
|
||||
grid("root", 3, [placement("active", 0, 0), placement("middle", 1, 0), placement("last", 2, 0)]),
|
||||
);
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
const first = accept(previewGridMove(transaction, { targetGridId: "root", x: 1, y: 0 }));
|
||||
const second = accept(previewGridMove(first, { targetGridId: "root", x: 2, y: 0 }));
|
||||
|
||||
expect(coordinatesById(first.preview, "root")).toEqual({
|
||||
active: { x: 1, y: 0, w: 1, h: 1 },
|
||||
middle: { x: 0, y: 0, w: 1, h: 1 },
|
||||
last: { x: 2, y: 0, w: 1, h: 1 },
|
||||
});
|
||||
expect(coordinatesById(second.preview, "root")).toEqual({
|
||||
active: { x: 2, y: 0, w: 1, h: 1 },
|
||||
middle: { x: 1, y: 0, w: 1, h: 1 },
|
||||
last: { x: 0, y: 0, w: 1, h: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-grid movement", () => {
|
||||
test("updates source and target atomically without compacting the source gap", () => {
|
||||
const original = state(
|
||||
grid("source", 2, [placement("active", 0, 0), placement("source-neighbor", 0, 1)]),
|
||||
grid("target", 2, [placement("target-neighbor", 0, 0)]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "target",
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(moved.preview, "source")).toEqual({
|
||||
"source-neighbor": { x: 0, y: 1, w: 1, h: 1 },
|
||||
});
|
||||
expect(coordinatesById(moved.preview, "target")).toEqual({
|
||||
"target-neighbor": { x: 0, y: 1, w: 1, h: 1 },
|
||||
active: { x: 0, y: 0, w: 1, h: 1 },
|
||||
});
|
||||
expect(getPlacement(moved.preview, "active").marker).toBe("metadata:active");
|
||||
expect(original).toEqual(
|
||||
state(
|
||||
grid("source", 2, [placement("active", 0, 0), placement("source-neighbor", 0, 1)]),
|
||||
grid("target", 2, [placement("target-neighbor", 0, 0)]),
|
||||
),
|
||||
);
|
||||
expectValidState(moved.preview);
|
||||
});
|
||||
|
||||
test("chained target changes do not leak an earlier cross-grid preview", () => {
|
||||
const original = state(
|
||||
grid("source", 2, [placement("active", 0, 0)]),
|
||||
grid("first-target", 2, [placement("first-neighbor", 0, 0)]),
|
||||
grid("second-target", 2, [placement("second-neighbor", 0, 0)]),
|
||||
);
|
||||
const first = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "first-target",
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
const second = accept(previewGridMove(first, { targetGridId: "second-target", x: 0, y: 0 }));
|
||||
|
||||
expect(getGrid(second.preview, "first-target").placements).toEqual(getGrid(original, "first-target").placements);
|
||||
expect(getGrid(second.preview, "source").placements).toHaveLength(0);
|
||||
expect(getPlacement(second.preview, "active")).toMatchObject({ x: 0, y: 0 });
|
||||
expect(getPlacement(second.preview, "second-neighbor")).toMatchObject({ x: 0, y: 1 });
|
||||
});
|
||||
|
||||
test("moves a container's owned grid with it and supports commit/cancel", () => {
|
||||
const original = state(
|
||||
grid("source", 3, [placement("container", 0, 0, 2, 2, "container")]),
|
||||
grid("target", 3, []),
|
||||
grid("container-content", 2, [placement("child", 0, 0)], {
|
||||
parentGridId: "source",
|
||||
ownerPlacementId: "container",
|
||||
}),
|
||||
);
|
||||
const transaction = beginGridTransaction(original, { activeId: "container" });
|
||||
const moved = accept(previewGridMove(transaction, { targetGridId: "target", x: 1, y: 2 }));
|
||||
|
||||
expect(getGrid(moved.preview, "container-content").parentGridId).toBe("target");
|
||||
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 2, kind: "container" });
|
||||
expect(cancelGridTransaction(moved)).toEqual(original);
|
||||
expect(commitGridTransaction(moved)).toEqual(moved.preview);
|
||||
expect(commitGridTransaction(moved)).not.toBe(moved.preview);
|
||||
});
|
||||
|
||||
test.each(["child", "grandchild"])("rejects a container move into its %s grid", (targetGridId) => {
|
||||
const original = state(
|
||||
grid("root", 4, [placement("container", 0, 0, 2, 2, "container")]),
|
||||
grid("child", 3, [placement("nested-container", 0, 0, 2, 2, "container")], {
|
||||
parentGridId: "root",
|
||||
ownerPlacementId: "container",
|
||||
}),
|
||||
grid("grandchild", 2, [], {
|
||||
parentGridId: "child",
|
||||
ownerPlacementId: "nested-container",
|
||||
}),
|
||||
);
|
||||
const transaction = beginGridTransaction(original, { activeId: "container" });
|
||||
const result = previewGridMove(transaction, { targetGridId, x: 0, y: 0 });
|
||||
|
||||
expect(result.accepted).toBe(false);
|
||||
if (result.accepted) throw new Error("Expected descendant move rejection");
|
||||
expect(result.rejection.code).toBe("descendant-target");
|
||||
expect(result.transaction).toBe(transaction);
|
||||
});
|
||||
|
||||
test("runs caller guards with hierarchy context and preserves custom rejection details", () => {
|
||||
const original = state(grid("source", 2, [placement("active", 0, 0)]), grid("target", 2, []));
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
let observedTarget = "";
|
||||
const guarded = previewGridMove(
|
||||
transaction,
|
||||
{ targetGridId: "target", x: 0, y: 0 },
|
||||
{
|
||||
guards: [
|
||||
(context) => {
|
||||
observedTarget = context.targetGrid.id;
|
||||
expect(context.active.id).toBe("active");
|
||||
expect(context.sourceGrid.id).toBe("source");
|
||||
expect(context.targetIsDescendant).toBe(false);
|
||||
expect(context.snapshot).toBe(transaction.snapshot);
|
||||
return true;
|
||||
},
|
||||
() => ({ code: "locked-container", message: "Unlock the container first" }),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(observedTarget).toBe("target");
|
||||
expect(guarded.accepted).toBe(false);
|
||||
if (guarded.accepted) throw new Error("Expected custom guard rejection");
|
||||
expect(guarded.rejection).toEqual({ code: "locked-container", message: "Unlock the container first" });
|
||||
|
||||
const booleanRejection = previewGridMove(
|
||||
transaction,
|
||||
{ targetGridId: "target", x: 0, y: 0 },
|
||||
{
|
||||
guards: [() => false],
|
||||
},
|
||||
);
|
||||
expect(booleanRejection.accepted).toBe(false);
|
||||
if (booleanRejection.accepted) throw new Error("Expected boolean guard rejection");
|
||||
expect(booleanRejection.rejection.code).toBe("guard-rejected");
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "unknown grid",
|
||||
original: state(grid("source", 2, [placement("active", 0, 0)])),
|
||||
input: { targetGridId: "missing", x: 0, y: 0 },
|
||||
code: "unknown-target-grid",
|
||||
},
|
||||
{
|
||||
name: "destination narrower than the item",
|
||||
original: state(grid("source", 3, [placement("active", 0, 0, 2)]), grid("target", 1, [])),
|
||||
input: { targetGridId: "target", x: 0, y: 0 },
|
||||
code: "item-too-large",
|
||||
},
|
||||
{
|
||||
name: "reflow beyond destination row limit",
|
||||
original: state(
|
||||
grid("source", 2, [placement("active", 0, 0, 2)]),
|
||||
grid("target", 2, [placement("target", 0, 0, 2)], { maxRowCount: 1 }),
|
||||
),
|
||||
input: { targetGridId: "target", x: 0, y: 0 },
|
||||
code: "row-limit",
|
||||
},
|
||||
{
|
||||
name: "non-finite coordinate",
|
||||
original: state(grid("source", 2, [placement("active", 0, 0)])),
|
||||
input: { targetGridId: "source", x: Number.NaN, y: 0 },
|
||||
code: "invalid-coordinate",
|
||||
},
|
||||
])("rejects $name without changing the transaction", ({ original, input, code }) => {
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
const result = previewGridMove(transaction, input);
|
||||
|
||||
expect(result.accepted).toBe(false);
|
||||
if (result.accepted) throw new Error("Expected move rejection");
|
||||
expect(result.rejection.code).toBe(code);
|
||||
expect(result.transaction).toBe(transaction);
|
||||
expect(result.transaction.preview).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("eight-direction resize", () => {
|
||||
test.each<{
|
||||
direction: GridResizeDirection;
|
||||
deltaColumns: number;
|
||||
deltaRows: number;
|
||||
expected: Pick<TestPlacement, "x" | "y" | "w" | "h">;
|
||||
}>([
|
||||
{ direction: "n", deltaColumns: 0, deltaRows: -1, expected: { x: 3, y: 2, w: 4, h: 5 } },
|
||||
{ direction: "ne", deltaColumns: 1, deltaRows: -1, expected: { x: 3, y: 2, w: 5, h: 5 } },
|
||||
{ direction: "e", deltaColumns: 1, deltaRows: 0, expected: { x: 3, y: 3, w: 5, h: 4 } },
|
||||
{ direction: "se", deltaColumns: 1, deltaRows: 1, expected: { x: 3, y: 3, w: 5, h: 5 } },
|
||||
{ direction: "s", deltaColumns: 0, deltaRows: 1, expected: { x: 3, y: 3, w: 4, h: 5 } },
|
||||
{ direction: "sw", deltaColumns: -1, deltaRows: 1, expected: { x: 2, y: 3, w: 5, h: 5 } },
|
||||
{ direction: "w", deltaColumns: -1, deltaRows: 0, expected: { x: 2, y: 3, w: 5, h: 4 } },
|
||||
{ direction: "nw", deltaColumns: -1, deltaRows: -1, expected: { x: 2, y: 2, w: 5, h: 5 } },
|
||||
])("resizes from $direction while anchoring the opposite edge", ({ expected, ...input }) => {
|
||||
const original = state(grid("root", 12, [placement("active", 3, 3, 4, 4)], { maxRowCount: 12 }));
|
||||
const resized = accept(previewGridResize(beginGridTransaction(original, { activeId: "active" }), input));
|
||||
|
||||
expect(getPlacement(resized.preview, "active")).toMatchObject(expected);
|
||||
});
|
||||
|
||||
test("clamps a north-west shrink to minimum size while keeping right and bottom anchored", () => {
|
||||
const original = state(grid("root", 12, [placement("active", 3, 3, 4, 4)], { maxRowCount: 12 }));
|
||||
const resized = accept(
|
||||
previewGridResize(beginGridTransaction(original, { activeId: "active" }), {
|
||||
direction: "nw",
|
||||
deltaColumns: 10,
|
||||
deltaRows: 10,
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(resized.preview, "active")).toMatchObject({ x: 5, y: 5, w: 2, h: 2 });
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ direction: "nw" as const, deltaColumns: -99, deltaRows: -99, expected: { x: 0, y: 0, w: 7, h: 7 } },
|
||||
{ direction: "se" as const, deltaColumns: 99, deltaRows: 99, expected: { x: 3, y: 3, w: 9, h: 9 } },
|
||||
])("clamps $direction growth to grid bounds", ({ expected, ...input }) => {
|
||||
const original = state(grid("root", 12, [placement("active", 3, 3, 4, 4)], { maxRowCount: 12 }));
|
||||
const resized = accept(previewGridResize(beginGridTransaction(original, { activeId: "active" }), input));
|
||||
|
||||
expect(getPlacement(resized.preview, "active")).toMatchObject(expected);
|
||||
});
|
||||
|
||||
test("honors explicit maximum dimensions", () => {
|
||||
const original = state(grid("root", 12, [placement("active", 3, 3, 4, 4)], { maxRowCount: 12 }));
|
||||
const resized = accept(
|
||||
previewGridResize(beginGridTransaction(original, { activeId: "active" }), {
|
||||
direction: "se",
|
||||
deltaColumns: 99,
|
||||
deltaRows: 99,
|
||||
maxWidth: 6,
|
||||
maxHeight: 5,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(resized.preview, "active")).toMatchObject({ x: 3, y: 3, w: 6, h: 5 });
|
||||
});
|
||||
|
||||
test("pins the resized item and deterministically pushes collisions", () => {
|
||||
const original = state(
|
||||
grid("root", 3, [placement("active", 0, 0, 2), placement("neighbor", 0, 1, 2), placement("side", 2, 2)]),
|
||||
);
|
||||
const resized = accept(
|
||||
previewGridResize(beginGridTransaction(original, { activeId: "active" }), {
|
||||
direction: "s",
|
||||
deltaColumns: 0,
|
||||
deltaRows: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(resized.preview, "root")).toEqual({
|
||||
active: { x: 0, y: 0, w: 2, h: 3 },
|
||||
neighbor: { x: 0, y: 3, w: 2, h: 1 },
|
||||
side: { x: 2, y: 2, w: 1, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects resize when collision reflow would exceed the row limit", () => {
|
||||
const original = state(
|
||||
grid("root", 2, [placement("active", 0, 0, 2), placement("neighbor", 0, 1, 2, 2)], {
|
||||
maxRowCount: 3,
|
||||
}),
|
||||
);
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
const result = previewGridResize(transaction, {
|
||||
direction: "s",
|
||||
deltaColumns: 0,
|
||||
deltaRows: 1,
|
||||
});
|
||||
|
||||
expect(result.accepted).toBe(false);
|
||||
if (result.accepted) throw new Error("Expected row-limit rejection");
|
||||
expect(result.rejection.code).toBe("row-limit");
|
||||
expect(result.transaction).toBe(transaction);
|
||||
});
|
||||
|
||||
test("rejects non-finite deltas and geometrically impossible constraints", () => {
|
||||
const original = state(grid("root", 4, [placement("active", 0, 0, 2, 2)]));
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
const invalidDelta = previewGridResize(transaction, {
|
||||
direction: "e",
|
||||
deltaColumns: Number.POSITIVE_INFINITY,
|
||||
deltaRows: 0,
|
||||
});
|
||||
const impossible = previewGridResize(transaction, {
|
||||
direction: "w",
|
||||
deltaColumns: 0,
|
||||
deltaRows: 0,
|
||||
minWidth: 5,
|
||||
maxWidth: 5,
|
||||
});
|
||||
|
||||
expect(invalidDelta.accepted).toBe(false);
|
||||
if (invalidDelta.accepted) throw new Error("Expected invalid-coordinate rejection");
|
||||
expect(invalidDelta.rejection.code).toBe("invalid-coordinate");
|
||||
expect(impossible.accepted).toBe(false);
|
||||
if (impossible.accepted) throw new Error("Expected resize-constraints rejection");
|
||||
expect(impossible.rejection.code).toBe("resize-constraints");
|
||||
});
|
||||
|
||||
test.each([{ minWidth: 0 }, { minHeight: 1.5 }, { minWidth: 3, maxWidth: 2 }, { minHeight: 3, maxHeight: 2 }])(
|
||||
"throws on invalid programmer-supplied constraints: $minWidth $minHeight",
|
||||
(constraints) => {
|
||||
const original = state(grid("root", 4, [placement("active", 0, 0, 2, 2)]));
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
|
||||
expect(() =>
|
||||
previewGridResize(transaction, {
|
||||
direction: "se",
|
||||
deltaColumns: 0,
|
||||
deltaRows: 0,
|
||||
...constraints,
|
||||
}),
|
||||
).toThrow(RangeError);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("transaction completion", () => {
|
||||
test("a rejection keeps the latest accepted preview, cancel restores start, and commit detaches output", () => {
|
||||
const original = state(grid("root", 3, [placement("active", 0, 0), placement("other", 1, 0)]));
|
||||
const started = beginGridTransaction(original, { activeId: "active" });
|
||||
const moved = accept(previewGridMove(started, { targetGridId: "root", x: 1, y: 0 }));
|
||||
const rejected = previewGridMove(moved, { targetGridId: "missing", x: 0, y: 0 });
|
||||
|
||||
expect(rejected.accepted).toBe(false);
|
||||
if (rejected.accepted) throw new Error("Expected rejection");
|
||||
expect(rejected.transaction).toBe(moved);
|
||||
expect(rejected.transaction.preview).toEqual(moved.preview);
|
||||
|
||||
const committed = commitGridTransaction(rejected.transaction);
|
||||
const cancelled = cancelGridTransaction(rejected.transaction);
|
||||
expect(committed).toEqual(moved.preview);
|
||||
expect(committed).not.toBe(moved.preview);
|
||||
expect(cancelled).toEqual(original);
|
||||
expect(cancelled).not.toBe(rejected.transaction.snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interaction invariant matrix", () => {
|
||||
test("preserves invariants across every sampled same-grid and cross-grid coordinate", () => {
|
||||
const original = state(
|
||||
grid(
|
||||
"source",
|
||||
6,
|
||||
[
|
||||
placement("active", 0, 0, 2, 2),
|
||||
placement("source-a", 2, 0, 2),
|
||||
placement("source-b", 4, 0, 2, 2),
|
||||
placement("source-c", 2, 1, 2),
|
||||
],
|
||||
{ maxRowCount: 10 },
|
||||
),
|
||||
grid("target", 5, [placement("target-a", 0, 0), placement("target-b", 1, 0, 2, 2)], {
|
||||
maxRowCount: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const targetGridId of ["source", "target"]) {
|
||||
for (let x = -3; x <= 8; x += 1) {
|
||||
for (let y = -2; y <= 11; y += 1) {
|
||||
const result = previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
if (!result.accepted) {
|
||||
expect(result.rejection.code).toBe("row-limit");
|
||||
continue;
|
||||
}
|
||||
|
||||
expectValidState(result.transaction.preview);
|
||||
expect(getPlacement(result.transaction.preview, "active").marker).toBe("metadata:active");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves invariants across every direction and representative signed deltas", () => {
|
||||
const original = state(grid("root", 8, [placement("active", 2, 2, 3, 3)], { maxRowCount: 9 }));
|
||||
const directions: readonly GridResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
|
||||
const deltas = [-20, -3, -1, 0, 1, 3, 20];
|
||||
|
||||
for (const direction of directions) {
|
||||
for (const deltaColumns of deltas) {
|
||||
for (const deltaRows of deltas) {
|
||||
const result = previewGridResize(beginGridTransaction(original, { activeId: "active" }), {
|
||||
direction,
|
||||
deltaColumns,
|
||||
deltaRows,
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
maxWidth: 6,
|
||||
maxHeight: 7,
|
||||
});
|
||||
expect(result.accepted).toBe(true);
|
||||
if (!result.accepted) throw new Error(`Unexpected ${result.rejection.code} for ${direction}`);
|
||||
expectValidState(result.transaction.preview);
|
||||
expect(getPlacement(result.transaction.preview, "active").marker).toBe("metadata:active");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,498 @@
|
||||
import { doGridPlacementsOverlap, getLayoutRowCount, normalizeGridPlacement } from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import type {
|
||||
BeginGridTransactionInput,
|
||||
GridMoveGuard,
|
||||
GridMoveInput,
|
||||
GridMoveOptions,
|
||||
GridOperationRejection,
|
||||
GridPreviewResult,
|
||||
GridResizeDirection,
|
||||
GridResizeInput,
|
||||
GridTransaction,
|
||||
TransactionalGrid,
|
||||
TransactionalGridState,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Starts an immutable interaction transaction. The returned snapshot is the
|
||||
* rollback source for dnd-kit's `onDragCancel` and the baseline for every
|
||||
* `onDragMove` / `onDragOver` preview.
|
||||
*/
|
||||
export const beginGridTransaction = <TPlacement extends GridPlacement>(
|
||||
state: TransactionalGridState<TPlacement>,
|
||||
input: BeginGridTransactionInput,
|
||||
): GridTransaction<TPlacement> => {
|
||||
validateGridState(state);
|
||||
|
||||
const located = findPlacement(state, input.activeId);
|
||||
if (!located) throw new Error(`Grid item "${input.activeId}" does not exist`);
|
||||
if (input.sourceGridId !== undefined && located.grid.id !== input.sourceGridId) {
|
||||
throw new Error(`Grid item "${input.activeId}" belongs to grid "${located.grid.id}", not "${input.sourceGridId}"`);
|
||||
}
|
||||
|
||||
const snapshot = cloneGridState(state);
|
||||
return {
|
||||
activeId: input.activeId,
|
||||
sourceGridId: located.grid.id,
|
||||
snapshot,
|
||||
preview: cloneGridState(snapshot),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes a move preview from the transaction snapshot. Same-grid moves that
|
||||
* collide with one item swap into that item's slot when both resulting
|
||||
* placements remain valid; every other move pins the active item and pushes
|
||||
* only actual collisions. Intentional empty rows remain untouched.
|
||||
*/
|
||||
export const previewGridMove = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
input: GridMoveInput,
|
||||
options: GridMoveOptions<TPlacement> = {},
|
||||
): GridPreviewResult<TPlacement> => {
|
||||
const sourceGrid = getGrid(transaction.snapshot, transaction.sourceGridId);
|
||||
const targetGrid = findGrid(transaction.snapshot, input.targetGridId);
|
||||
if (!targetGrid) {
|
||||
return rejected(transaction, {
|
||||
code: "unknown-target-grid",
|
||||
message: `Grid "${input.targetGridId}" does not exist`,
|
||||
});
|
||||
}
|
||||
|
||||
const active = getPlacement(sourceGrid, transaction.activeId);
|
||||
const targetIsDescendant = isTargetWithinActiveSubtree(transaction.snapshot, targetGrid.id, transaction.activeId);
|
||||
if (targetIsDescendant) {
|
||||
return rejected(transaction, {
|
||||
code: "descendant-target",
|
||||
message: `Grid "${targetGrid.id}" is inside item "${active.id}"`,
|
||||
});
|
||||
}
|
||||
|
||||
const guardRejection = evaluateMoveGuards(options.guards ?? [], {
|
||||
active,
|
||||
sourceGrid,
|
||||
targetGrid,
|
||||
snapshot: transaction.snapshot,
|
||||
targetIsDescendant,
|
||||
});
|
||||
if (guardRejection) return rejected(transaction, guardRejection);
|
||||
|
||||
if (!Number.isFinite(input.x) || !Number.isFinite(input.y)) {
|
||||
return rejected(transaction, { code: "invalid-coordinate" });
|
||||
}
|
||||
if (active.w > targetGrid.columnCount || active.h > getMaximumRows(targetGrid)) {
|
||||
return rejected(transaction, { code: "item-too-large" });
|
||||
}
|
||||
|
||||
const nextActive = clampMovePlacement(active, input, targetGrid);
|
||||
const preview =
|
||||
sourceGrid.id === targetGrid.id
|
||||
? previewSameGridMove(transaction.snapshot, sourceGrid, active, nextActive)
|
||||
: previewCrossGridMove(transaction.snapshot, sourceGrid, targetGrid, active, nextActive);
|
||||
|
||||
if (!fitsRowLimits(preview)) return rejected(transaction, { code: "row-limit" });
|
||||
validateGridState(preview);
|
||||
return accepted(transaction, preview);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes an eight-direction resize preview. Deltas describe movement of the
|
||||
* selected handle, while the opposite edge stays anchored. Bounds and caller
|
||||
* constraints are applied before pinned collision reflow.
|
||||
*/
|
||||
export const previewGridResize = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
input: GridResizeInput,
|
||||
): GridPreviewResult<TPlacement> => {
|
||||
const grid = getGrid(transaction.snapshot, transaction.sourceGridId);
|
||||
const active = getPlacement(grid, transaction.activeId);
|
||||
|
||||
if (!Number.isFinite(input.deltaColumns) || !Number.isFinite(input.deltaRows)) {
|
||||
return rejected(transaction, { code: "invalid-coordinate" });
|
||||
}
|
||||
|
||||
const constraints = getResizeConstraints(input, grid);
|
||||
const resized = resizePlacement(active, input.direction, input, constraints, grid);
|
||||
if (!resized) return rejected(transaction, { code: "resize-constraints" });
|
||||
|
||||
const preview = cloneGridState(transaction.snapshot);
|
||||
const previewGrid = getGrid(preview, grid.id);
|
||||
previewGrid.placements = areCoordinatesEqual(active, resized)
|
||||
? previewGrid.placements
|
||||
: resolvePinnedGridCollisions(previewGrid.placements, resized, previewGrid.columnCount);
|
||||
|
||||
if (!fitsRowLimits(preview)) return rejected(transaction, { code: "row-limit" });
|
||||
validateGridState(preview);
|
||||
return accepted(transaction, preview);
|
||||
};
|
||||
|
||||
/** Returns the latest accepted preview as one atomic state update. */
|
||||
export const commitGridTransaction = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
): TransactionalGridState<TPlacement> => cloneGridState(transaction.preview);
|
||||
|
||||
/** Restores the exact interaction-start snapshot. */
|
||||
export const cancelGridTransaction = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
): TransactionalGridState<TPlacement> => cloneGridState(transaction.snapshot);
|
||||
|
||||
const previewSameGridMove = <TPlacement extends GridPlacement>(
|
||||
state: TransactionalGridState<TPlacement>,
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
active: TPlacement,
|
||||
nextActive: TPlacement,
|
||||
) => {
|
||||
const preview = cloneGridState(state);
|
||||
const previewGrid = getGrid(preview, grid.id);
|
||||
if (areCoordinatesEqual(active, nextActive)) return preview;
|
||||
|
||||
const collisions = grid.placements.filter(
|
||||
(placement) => placement.id !== active.id && doGridPlacementsOverlap(placement, nextActive),
|
||||
);
|
||||
const displaced = collisions.length === 1 ? collisions[0] : undefined;
|
||||
|
||||
const swapped = displaced
|
||||
? grid.placements.map((placement) => {
|
||||
if (placement.id === active.id) return { ...placement, x: displaced.x, y: displaced.y };
|
||||
if (placement.id === displaced.id) return { ...placement, x: active.x, y: active.y };
|
||||
return { ...placement };
|
||||
})
|
||||
: null;
|
||||
|
||||
previewGrid.placements =
|
||||
swapped && placementsFitGrid(swapped, grid)
|
||||
? swapped
|
||||
: resolvePinnedGridCollisions(grid.placements, nextActive, grid.columnCount);
|
||||
return preview;
|
||||
};
|
||||
|
||||
const placementsFitGrid = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
) =>
|
||||
placements.every(
|
||||
(placement) =>
|
||||
placement.x >= 0 &&
|
||||
placement.y >= 0 &&
|
||||
placement.x + placement.w <= grid.columnCount &&
|
||||
(grid.maxRowCount === undefined || grid.maxRowCount === null || placement.y + placement.h <= grid.maxRowCount),
|
||||
) &&
|
||||
placements.every((placement, index) =>
|
||||
placements.slice(index + 1).every((other) => !doGridPlacementsOverlap(placement, other)),
|
||||
);
|
||||
|
||||
const previewCrossGridMove = <TPlacement extends GridPlacement>(
|
||||
state: TransactionalGridState<TPlacement>,
|
||||
sourceGrid: TransactionalGrid<TPlacement>,
|
||||
targetGrid: TransactionalGrid<TPlacement>,
|
||||
active: TPlacement,
|
||||
nextActive: TPlacement,
|
||||
) => {
|
||||
const preview = cloneGridState(state);
|
||||
const previewSource = getGrid(preview, sourceGrid.id);
|
||||
const previewTarget = getGrid(preview, targetGrid.id);
|
||||
|
||||
previewSource.placements = sourceGrid.placements
|
||||
.filter((placement) => placement.id !== active.id)
|
||||
.map((placement) => ({ ...placement }));
|
||||
previewTarget.placements = resolvePinnedGridCollisions(
|
||||
[...targetGrid.placements, nextActive],
|
||||
nextActive,
|
||||
targetGrid.columnCount,
|
||||
);
|
||||
|
||||
preview.grids = preview.grids.map((grid) =>
|
||||
grid.ownerPlacementId === active.id ? { ...grid, parentGridId: targetGrid.id } : grid,
|
||||
);
|
||||
return preview;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pins the active placement, keeps every non-colliding item at its persisted
|
||||
* coordinates, and pushes only the deterministic collision chain downward.
|
||||
*/
|
||||
export const resolvePinnedGridCollisions = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
pinnedPlacement: TPlacement,
|
||||
columnCount: number,
|
||||
): TPlacement[] => {
|
||||
const normalized = placements.map((placement) =>
|
||||
normalizeGridPlacement(placement.id === pinnedPlacement.id ? pinnedPlacement : placement, columnCount),
|
||||
);
|
||||
const pinned = normalized.find((placement) => placement.id === pinnedPlacement.id);
|
||||
if (!pinned) throw new Error(`Pinned grid item "${pinnedPlacement.id}" does not exist`);
|
||||
|
||||
const placed: TPlacement[] = [pinned];
|
||||
const candidates = normalized.filter((placement) => placement.id !== pinned.id).toSorted(comparePlacements);
|
||||
for (const candidate of candidates) {
|
||||
let resolved = candidate;
|
||||
for (;;) {
|
||||
const collisions = placed.filter((placement) => doGridPlacementsOverlap(resolved, placement));
|
||||
if (collisions.length === 0) break;
|
||||
resolved = {
|
||||
...resolved,
|
||||
y: Math.max(...collisions.map((placement) => placement.y + placement.h)),
|
||||
};
|
||||
}
|
||||
placed.push(resolved);
|
||||
}
|
||||
|
||||
const byId = new Map(placed.map((placement) => [placement.id, placement]));
|
||||
return normalized.map((placement) => byId.get(placement.id) ?? placement);
|
||||
};
|
||||
|
||||
const comparePlacements = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.y - second.y || first.x - second.x || first.id.localeCompare(second.id);
|
||||
|
||||
const clampMovePlacement = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
input: GridMoveInput,
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
) => {
|
||||
const maximumX = grid.columnCount - placement.w;
|
||||
const maximumY = getMaximumRows(grid) - placement.h;
|
||||
return {
|
||||
...placement,
|
||||
x: clamp(Math.trunc(input.x), 0, maximumX),
|
||||
y: clamp(Math.trunc(input.y), 0, maximumY),
|
||||
};
|
||||
};
|
||||
|
||||
interface ResolvedResizeConstraints {
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
const getResizeConstraints = <TPlacement extends GridPlacement>(
|
||||
input: GridResizeInput,
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
): ResolvedResizeConstraints => {
|
||||
const minWidth = toPositiveInteger(input.minWidth ?? 1, "minWidth");
|
||||
const minHeight = toPositiveInteger(input.minHeight ?? 1, "minHeight");
|
||||
const maxWidth = toPositiveInteger(input.maxWidth ?? grid.columnCount, "maxWidth");
|
||||
const maxHeight = toPositiveInteger(input.maxHeight ?? getMaximumRows(grid), "maxHeight");
|
||||
if (maxWidth < minWidth) throw new RangeError("maxWidth must be greater than or equal to minWidth");
|
||||
if (maxHeight < minHeight) throw new RangeError("maxHeight must be greater than or equal to minHeight");
|
||||
|
||||
return { minWidth, minHeight, maxWidth, maxHeight };
|
||||
};
|
||||
|
||||
const resizePlacement = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
direction: GridResizeDirection,
|
||||
input: GridResizeInput,
|
||||
constraints: ResolvedResizeConstraints,
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
): TPlacement | null => {
|
||||
const deltaX = Math.trunc(input.deltaColumns);
|
||||
const deltaY = Math.trunc(input.deltaRows);
|
||||
const resizeWest = direction.includes("w");
|
||||
const resizeEast = direction.includes("e");
|
||||
const resizeNorth = direction.includes("n");
|
||||
const resizeSouth = direction.includes("s");
|
||||
|
||||
let x = placement.x;
|
||||
let y = placement.y;
|
||||
let width = placement.w;
|
||||
let height = placement.h;
|
||||
|
||||
if (resizeWest) {
|
||||
const fixedRight = placement.x + placement.w;
|
||||
const maximumWidth = Math.min(constraints.maxWidth, fixedRight);
|
||||
if (constraints.minWidth > maximumWidth) return null;
|
||||
width = clamp(placement.w - deltaX, constraints.minWidth, maximumWidth);
|
||||
x = fixedRight - width;
|
||||
} else if (resizeEast) {
|
||||
const maximumWidth = Math.min(constraints.maxWidth, grid.columnCount - placement.x);
|
||||
if (constraints.minWidth > maximumWidth) return null;
|
||||
width = clamp(placement.w + deltaX, constraints.minWidth, maximumWidth);
|
||||
}
|
||||
|
||||
if (resizeNorth) {
|
||||
const fixedBottom = placement.y + placement.h;
|
||||
const maximumHeight = Math.min(constraints.maxHeight, fixedBottom);
|
||||
if (constraints.minHeight > maximumHeight) return null;
|
||||
height = clamp(placement.h - deltaY, constraints.minHeight, maximumHeight);
|
||||
y = fixedBottom - height;
|
||||
} else if (resizeSouth) {
|
||||
const maximumHeight = Math.min(constraints.maxHeight, getMaximumRows(grid) - placement.y);
|
||||
if (constraints.minHeight > maximumHeight) return null;
|
||||
height = clamp(placement.h + deltaY, constraints.minHeight, maximumHeight);
|
||||
}
|
||||
|
||||
return { ...placement, x, y, w: width, h: height };
|
||||
};
|
||||
|
||||
const evaluateMoveGuards = <TPlacement extends GridPlacement>(
|
||||
guards: readonly GridMoveGuard<TPlacement>[],
|
||||
context: Parameters<GridMoveGuard<TPlacement>>[0],
|
||||
): GridOperationRejection | null => {
|
||||
for (const guard of guards) {
|
||||
const result = guard(context);
|
||||
if (result === true) continue;
|
||||
if (result === false) return { code: "guard-rejected" };
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isTargetWithinActiveSubtree = <TPlacement extends GridPlacement>(
|
||||
state: TransactionalGridState<TPlacement>,
|
||||
targetGridId: string,
|
||||
activeId: string,
|
||||
) => {
|
||||
let current: TransactionalGrid<TPlacement> | undefined = findGrid(state, targetGridId);
|
||||
const visited = new Set<string>();
|
||||
while (current) {
|
||||
if (current.ownerPlacementId === activeId) return true;
|
||||
if (!current.parentGridId || visited.has(current.id)) return false;
|
||||
visited.add(current.id);
|
||||
current = findGrid(state, current.parentGridId);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const accepted = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
preview: TransactionalGridState<TPlacement>,
|
||||
): GridPreviewResult<TPlacement> => ({
|
||||
accepted: true,
|
||||
transaction: { ...transaction, preview },
|
||||
});
|
||||
|
||||
const rejected = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
rejection: GridOperationRejection,
|
||||
): GridPreviewResult<TPlacement> => ({ accepted: false, transaction, rejection });
|
||||
|
||||
const fitsRowLimits = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>) =>
|
||||
state.grids.every((grid) => {
|
||||
if (grid.maxRowCount === undefined || grid.maxRowCount === null) return true;
|
||||
return getLayoutRowCount(grid.placements) <= grid.maxRowCount;
|
||||
});
|
||||
|
||||
const getMaximumRows = <TPlacement extends GridPlacement>(grid: TransactionalGrid<TPlacement>) =>
|
||||
grid.maxRowCount ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const cloneGridState = <TPlacement extends GridPlacement>(
|
||||
state: TransactionalGridState<TPlacement>,
|
||||
): TransactionalGridState<TPlacement> => ({
|
||||
grids: state.grids.map((grid) => ({
|
||||
...grid,
|
||||
placements: grid.placements.map((placement) => ({ ...placement })),
|
||||
})),
|
||||
});
|
||||
|
||||
const findGrid = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>, gridId: string) =>
|
||||
state.grids.find((grid) => grid.id === gridId);
|
||||
|
||||
const getGrid = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>, gridId: string) => {
|
||||
const grid = findGrid(state, gridId);
|
||||
if (!grid) throw new Error(`Grid "${gridId}" does not exist`);
|
||||
return grid;
|
||||
};
|
||||
|
||||
const getPlacement = <TPlacement extends GridPlacement>(grid: TransactionalGrid<TPlacement>, itemId: string) => {
|
||||
const placement = grid.placements.find((candidate) => candidate.id === itemId);
|
||||
if (!placement) throw new Error(`Grid item "${itemId}" does not exist in grid "${grid.id}"`);
|
||||
return placement;
|
||||
};
|
||||
|
||||
const findPlacement = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>, itemId: string) => {
|
||||
for (const grid of state.grids) {
|
||||
const placement = grid.placements.find((candidate) => candidate.id === itemId);
|
||||
if (placement) return { grid, placement };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const areCoordinatesEqual = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
|
||||
|
||||
const clamp = (value: number, minimum: number, maximum: number) => Math.min(Math.max(value, minimum), maximum);
|
||||
|
||||
const toPositiveInteger = (value: number, name: string) => {
|
||||
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
|
||||
throw new RangeError(`${name} must be a positive integer`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const validateGridState = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>) => {
|
||||
const gridIds = new Set<string>();
|
||||
const placementIds = new Set<string>();
|
||||
const ownerIds = new Set<string>();
|
||||
|
||||
for (const grid of state.grids) {
|
||||
if (gridIds.has(grid.id)) throw new Error(`Duplicate grid id "${grid.id}"`);
|
||||
gridIds.add(grid.id);
|
||||
toPositiveInteger(grid.columnCount, `columnCount for grid "${grid.id}"`);
|
||||
if (grid.maxRowCount !== undefined && grid.maxRowCount !== null) {
|
||||
toPositiveInteger(grid.maxRowCount, `maxRowCount for grid "${grid.id}"`);
|
||||
}
|
||||
|
||||
for (const placement of grid.placements) {
|
||||
if (placementIds.has(placement.id)) throw new Error(`Duplicate grid item id "${placement.id}"`);
|
||||
placementIds.add(placement.id);
|
||||
validatePlacementBounds(placement, grid);
|
||||
}
|
||||
for (let index = 0; index < grid.placements.length; index += 1) {
|
||||
const placement = grid.placements[index];
|
||||
if (!placement) continue;
|
||||
for (const other of grid.placements.slice(index + 1)) {
|
||||
if (doGridPlacementsOverlap(placement, other)) {
|
||||
throw new Error(`Grid items "${placement.id}" and "${other.id}" overlap in grid "${grid.id}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const grid of state.grids) {
|
||||
if (grid.parentGridId && !gridIds.has(grid.parentGridId)) {
|
||||
throw new Error(`Parent grid "${grid.parentGridId}" for grid "${grid.id}" does not exist`);
|
||||
}
|
||||
if (grid.ownerPlacementId) {
|
||||
if (!grid.parentGridId) throw new Error(`Owned grid "${grid.id}" must have a parent grid`);
|
||||
if (ownerIds.has(grid.ownerPlacementId)) {
|
||||
throw new Error(`Grid item "${grid.ownerPlacementId}" owns more than one grid`);
|
||||
}
|
||||
ownerIds.add(grid.ownerPlacementId);
|
||||
const parent = getGrid(state, grid.parentGridId);
|
||||
if (!parent.placements.some((placement) => placement.id === grid.ownerPlacementId)) {
|
||||
throw new Error(`Owner item "${grid.ownerPlacementId}" does not exist in parent grid "${grid.parentGridId}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const grid of state.grids) {
|
||||
const visited = new Set<string>();
|
||||
let current: TransactionalGrid<TPlacement> | undefined = grid;
|
||||
while (current?.parentGridId) {
|
||||
if (visited.has(current.id)) throw new Error(`Grid hierarchy contains a cycle at "${current.id}"`);
|
||||
visited.add(current.id);
|
||||
current = getGrid(state, current.parentGridId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const validatePlacementBounds = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
) => {
|
||||
const values = [placement.x, placement.y, placement.w, placement.h];
|
||||
if (values.some((value) => !Number.isFinite(value) || !Number.isInteger(value))) {
|
||||
throw new Error(`Grid item "${placement.id}" must use finite integer coordinates`);
|
||||
}
|
||||
if (placement.x < 0 || placement.y < 0 || placement.w < 1 || placement.h < 1) {
|
||||
throw new Error(`Grid item "${placement.id}" has invalid bounds`);
|
||||
}
|
||||
if (placement.x + placement.w > grid.columnCount) {
|
||||
throw new Error(`Grid item "${placement.id}" exceeds grid "${grid.id}" columns`);
|
||||
}
|
||||
if (placement.y + placement.h > getMaximumRows(grid)) {
|
||||
throw new Error(`Grid item "${placement.id}" exceeds grid "${grid.id}" rows`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
export {
|
||||
beginGridTransaction,
|
||||
cancelGridTransaction,
|
||||
commitGridTransaction,
|
||||
previewGridMove,
|
||||
previewGridResize,
|
||||
resolvePinnedGridCollisions,
|
||||
} from "./engine";
|
||||
export {
|
||||
getContinuousGridDelta,
|
||||
getContinuousResizePlacement,
|
||||
getPointerProjectedShape,
|
||||
getSnappedGridCoordinates,
|
||||
getSnappedGridDelta,
|
||||
} from "./coordinates";
|
||||
export type { DragProjectionOrigin } from "./coordinates";
|
||||
export { getPreferredNestedExitTargetId } from "./targeting";
|
||||
export type { GridTargetGeometry } from "./targeting";
|
||||
export type {
|
||||
BeginGridTransactionInput,
|
||||
GridMoveGuard,
|
||||
GridMoveGuardContext,
|
||||
GridMoveGuardResult,
|
||||
GridMoveInput,
|
||||
GridMoveOptions,
|
||||
GridOperationRejection,
|
||||
GridPreviewResult,
|
||||
GridResizeDirection,
|
||||
GridResizeInput,
|
||||
GridTransaction,
|
||||
TransactionalGrid,
|
||||
TransactionalGridState,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { GridTargetGeometry } from "./targeting";
|
||||
import { getPreferredNestedExitTargetId } from "./targeting";
|
||||
|
||||
const grid = (
|
||||
id: string,
|
||||
rectangle: GridTargetGeometry["rectangle"],
|
||||
parentGridId: string | null = null,
|
||||
): GridTargetGeometry => ({ id, parentGridId, rectangle });
|
||||
|
||||
const alwaysEligible = () => true;
|
||||
|
||||
describe("nested grid exit targeting", () => {
|
||||
const grids = [
|
||||
grid("canvas", { left: 0, top: 0, width: 1200, height: 800 }),
|
||||
grid("container", { left: 200, top: 100, width: 600, height: 500 }, "canvas"),
|
||||
];
|
||||
|
||||
test("keeps the nested grid while most of a large item remains inside", () => {
|
||||
expect(
|
||||
getPreferredNestedExitTargetId({
|
||||
grids,
|
||||
pointerTargetId: "container",
|
||||
sourceGridId: "container",
|
||||
dragShape: { left: 100, top: 150, width: 400, height: 200 },
|
||||
isEligible: alwaysEligible,
|
||||
}),
|
||||
).toBe("container");
|
||||
});
|
||||
|
||||
test("selects the parent before the pointer leaves when most of the item is outside", () => {
|
||||
expect(
|
||||
getPreferredNestedExitTargetId({
|
||||
grids,
|
||||
pointerTargetId: "container",
|
||||
sourceGridId: "container",
|
||||
dragShape: { left: 0, top: 150, width: 400, height: 200 },
|
||||
isEligible: alwaysEligible,
|
||||
}),
|
||||
).toBe("canvas");
|
||||
});
|
||||
|
||||
test("preserves a sibling container selected by the pointer", () => {
|
||||
const withSibling = [...grids, grid("sibling", { left: 820, top: 100, width: 300, height: 500 }, "canvas")];
|
||||
expect(
|
||||
getPreferredNestedExitTargetId({
|
||||
grids: withSibling,
|
||||
pointerTargetId: "sibling",
|
||||
sourceGridId: "container",
|
||||
dragShape: { left: 820, top: 150, width: 300, height: 200 },
|
||||
isEligible: alwaysEligible,
|
||||
}),
|
||||
).toBe("sibling");
|
||||
});
|
||||
|
||||
test("does not escape to an ineligible parent", () => {
|
||||
expect(
|
||||
getPreferredNestedExitTargetId({
|
||||
grids,
|
||||
pointerTargetId: "container",
|
||||
sourceGridId: "container",
|
||||
dragShape: { left: 0, top: 150, width: 400, height: 200 },
|
||||
isEligible: (gridId) => gridId !== "canvas",
|
||||
}),
|
||||
).toBe("container");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
interface RectangleLike {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface GridTargetGeometry {
|
||||
id: string;
|
||||
parentGridId?: string | null;
|
||||
rectangle: RectangleLike;
|
||||
}
|
||||
|
||||
interface PreferredNestedExitTargetInput {
|
||||
grids: readonly GridTargetGeometry[];
|
||||
pointerTargetId: string;
|
||||
sourceGridId: string;
|
||||
dragShape: RectangleLike;
|
||||
isEligible: (gridId: string) => boolean;
|
||||
}
|
||||
|
||||
const NESTED_EXIT_RETAINED_AREA = 0.7;
|
||||
|
||||
/**
|
||||
* Keeps pointer precision for entering containers, but lets a large item leave
|
||||
* once a clear portion of its projected shape is visibly outside.
|
||||
*/
|
||||
export const getPreferredNestedExitTargetId = ({
|
||||
grids,
|
||||
pointerTargetId,
|
||||
sourceGridId,
|
||||
dragShape,
|
||||
isEligible,
|
||||
}: PreferredNestedExitTargetInput) => {
|
||||
const byId = new Map(grids.map((grid) => [grid.id, grid]));
|
||||
const source = byId.get(sourceGridId);
|
||||
let current = byId.get(pointerTargetId);
|
||||
if (!source || !current || !isSameGridOrAncestor(current.id, source, byId)) return pointerTargetId;
|
||||
|
||||
while (current.parentGridId) {
|
||||
if (getCoveredAreaRatio(dragShape, current.rectangle) > NESTED_EXIT_RETAINED_AREA) break;
|
||||
|
||||
const parent = byId.get(current.parentGridId);
|
||||
if (!parent || !isEligible(parent.id) || !containsCenter(parent.rectangle, dragShape)) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return current.id;
|
||||
};
|
||||
|
||||
const isSameGridOrAncestor = (
|
||||
candidateId: string,
|
||||
source: GridTargetGeometry,
|
||||
byId: ReadonlyMap<string, GridTargetGeometry>,
|
||||
) => {
|
||||
let current: GridTargetGeometry | undefined = source;
|
||||
const visited = new Set<string>();
|
||||
while (current) {
|
||||
if (current.id === candidateId) return true;
|
||||
if (!current.parentGridId || visited.has(current.id)) return false;
|
||||
visited.add(current.id);
|
||||
current = byId.get(current.parentGridId);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getCoveredAreaRatio = (shape: RectangleLike, target: RectangleLike) => {
|
||||
const shapeArea = shape.width * shape.height;
|
||||
if (!Number.isFinite(shapeArea) || shapeArea <= 0) return 1;
|
||||
|
||||
const width = Math.max(
|
||||
0,
|
||||
Math.min(shape.left + shape.width, target.left + target.width) - Math.max(shape.left, target.left),
|
||||
);
|
||||
const height = Math.max(
|
||||
0,
|
||||
Math.min(shape.top + shape.height, target.top + target.height) - Math.max(shape.top, target.top),
|
||||
);
|
||||
return (width * height) / shapeArea;
|
||||
};
|
||||
|
||||
const containsCenter = (target: RectangleLike, shape: RectangleLike) => {
|
||||
const center = { x: shape.left + shape.width / 2, y: shape.top + shape.height / 2 };
|
||||
return (
|
||||
center.x >= target.left &&
|
||||
center.x <= target.left + target.width &&
|
||||
center.y >= target.top &&
|
||||
center.y <= target.top + target.height
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
|
||||
/** A positioned grid with optional ownership metadata for nested containers. */
|
||||
export interface TransactionalGrid<TPlacement extends GridPlacement = GridPlacement> {
|
||||
id: string;
|
||||
columnCount: number;
|
||||
maxRowCount?: number | null;
|
||||
/** The grid containing the placement that owns this nested grid. */
|
||||
parentGridId?: string | null;
|
||||
/** The container placement whose content is rendered by this grid. */
|
||||
ownerPlacementId?: string | null;
|
||||
placements: readonly TPlacement[];
|
||||
}
|
||||
|
||||
/** The complete layout that must change atomically for cross-grid operations. */
|
||||
export interface TransactionalGridState<TPlacement extends GridPlacement = GridPlacement> {
|
||||
grids: readonly TransactionalGrid<TPlacement>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag or resize transaction. Every preview is recomputed from `snapshot`,
|
||||
* never from the previous preview, so noisy drag-over events cannot accumulate
|
||||
* coordinate drift.
|
||||
*/
|
||||
export interface GridTransaction<TPlacement extends GridPlacement = GridPlacement> {
|
||||
activeId: string;
|
||||
sourceGridId: string;
|
||||
snapshot: TransactionalGridState<TPlacement>;
|
||||
preview: TransactionalGridState<TPlacement>;
|
||||
}
|
||||
|
||||
export interface BeginGridTransactionInput {
|
||||
activeId: string;
|
||||
/** Optional disambiguation and stale-event protection. */
|
||||
sourceGridId?: string;
|
||||
}
|
||||
|
||||
export interface GridMoveInput {
|
||||
targetGridId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type GridResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
|
||||
|
||||
export interface GridResizeInput {
|
||||
direction: GridResizeDirection;
|
||||
/** Signed displacement of the resize handle in grid columns. */
|
||||
deltaColumns: number;
|
||||
/** Signed displacement of the resize handle in grid rows. */
|
||||
deltaRows: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export interface GridOperationRejection {
|
||||
code:
|
||||
| "descendant-target"
|
||||
| "guard-rejected"
|
||||
| "invalid-coordinate"
|
||||
| "item-too-large"
|
||||
| "resize-constraints"
|
||||
| "row-limit"
|
||||
| "unknown-target-grid"
|
||||
| (string & {});
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface GridMoveGuardContext<TPlacement extends GridPlacement = GridPlacement> {
|
||||
active: TPlacement;
|
||||
sourceGrid: TransactionalGrid<TPlacement>;
|
||||
targetGrid: TransactionalGrid<TPlacement>;
|
||||
snapshot: TransactionalGridState<TPlacement>;
|
||||
/** True when the target is owned by the active container or one of its descendants. */
|
||||
targetIsDescendant: boolean;
|
||||
}
|
||||
|
||||
export type GridMoveGuardResult = boolean | GridOperationRejection;
|
||||
export type GridMoveGuard<TPlacement extends GridPlacement = GridPlacement> = (
|
||||
context: GridMoveGuardContext<TPlacement>,
|
||||
) => GridMoveGuardResult;
|
||||
|
||||
export interface GridMoveOptions<TPlacement extends GridPlacement = GridPlacement> {
|
||||
guards?: readonly GridMoveGuard<TPlacement>[];
|
||||
}
|
||||
|
||||
export type GridPreviewResult<TPlacement extends GridPlacement = GridPlacement> =
|
||||
| {
|
||||
accepted: true;
|
||||
transaction: GridTransaction<TPlacement>;
|
||||
}
|
||||
| {
|
||||
accepted: false;
|
||||
transaction: GridTransaction<TPlacement>;
|
||||
rejection: GridOperationRejection;
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEvent, KeyboardEvent, PropsWithChildren } from "react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useCurrentLayout } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import {
|
||||
getEditableGridCellAttributes,
|
||||
getLogicalItemStyle,
|
||||
getReadonlyGridItemAttributes,
|
||||
} from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd";
|
||||
import type { GridTransaction } from "./dnd";
|
||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "./use-grid-layout-actions";
|
||||
import classes from "./section-grid.module.css";
|
||||
|
||||
interface FixedGridItemProps {
|
||||
item: ContainerSectionItem | SectionItem;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static SSR geometry in view mode and the stable content/keyboard handle in
|
||||
* edit mode. The focused tile owns grid-based keyboard movement while dnd-kit
|
||||
* handles pointer and touch input on the outer editor shell.
|
||||
*/
|
||||
export const FixedGridItem = ({
|
||||
item,
|
||||
minWidth = 1,
|
||||
minHeight = 1,
|
||||
children,
|
||||
}: PropsWithChildren<FixedGridItemProps>) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const runtimeStatus = useGridEditorRuntimeStatus();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const t = useI18n();
|
||||
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
|
||||
const { commitSectionGrid } = useGridLayoutActions();
|
||||
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
|
||||
const instructionsId = useId();
|
||||
const isEditorActive = isEditMode && runtimeStatus === "ready";
|
||||
const placement = toPlacement(item);
|
||||
const renderedPlacements = [...items, ...innerSections].map(toPlacement);
|
||||
const keyboardGridRef = useRef<{
|
||||
source: readonly SectionGridPlacement[];
|
||||
current: readonly SectionGridPlacement[];
|
||||
}>({
|
||||
source: renderedPlacements,
|
||||
current: renderedPlacements,
|
||||
});
|
||||
const displayName =
|
||||
item.type === "item"
|
||||
? item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`)
|
||||
: item.options.title.trim() || t("section.container.untitled");
|
||||
const accessibleLabel = t("item.moveResize.entryLabel", {
|
||||
name: displayName,
|
||||
column: String(placement.x + 1),
|
||||
row: String(placement.y + 1),
|
||||
});
|
||||
const semantics = isEditorActive
|
||||
? getEditableGridCellAttributes({ label: accessibleLabel, placement })
|
||||
: getReadonlyGridItemAttributes(placement);
|
||||
|
||||
const setKeyboardEditing = (next: boolean) => {
|
||||
setIsKeyboardEditing(next);
|
||||
announce(
|
||||
`${accessibleLabel}: ${t(next ? "item.moveResize.keyboard.started" : "item.moveResize.keyboard.stopped")}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget || !isEditorActive || event.altKey || event.ctrlKey || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setKeyboardEditing(!isKeyboardEditing);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && isKeyboardEditing) {
|
||||
event.preventDefault();
|
||||
setKeyboardEditing(false);
|
||||
return;
|
||||
}
|
||||
if (!isKeyboardEditing || !isArrowKey(event.key)) return;
|
||||
|
||||
event.preventDefault();
|
||||
if (document.body.hasAttribute("data-board-grid-interacting")) {
|
||||
setKeyboardEditing(false);
|
||||
return;
|
||||
}
|
||||
if (!arePlacementListsEqual(keyboardGridRef.current.source, renderedPlacements)) {
|
||||
keyboardGridRef.current = {
|
||||
source: renderedPlacements,
|
||||
current: renderedPlacements,
|
||||
};
|
||||
}
|
||||
const placements = keyboardGridRef.current.current;
|
||||
const current = placements.find((candidate) => candidate.id === item.id);
|
||||
if (!current) return;
|
||||
|
||||
let transaction: GridTransaction<SectionGridPlacement>;
|
||||
try {
|
||||
transaction = beginGridTransaction(
|
||||
{
|
||||
grids: [
|
||||
{
|
||||
id: section.id,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
placements,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ activeId: current.id, sourceGridId: section.id },
|
||||
);
|
||||
} catch {
|
||||
keyboardGridRef.current = {
|
||||
source: renderedPlacements,
|
||||
current: renderedPlacements,
|
||||
};
|
||||
setKeyboardEditing(false);
|
||||
return;
|
||||
}
|
||||
const delta = getKeyboardDelta(event.key);
|
||||
const result = event.shiftKey
|
||||
? previewGridResize(transaction, {
|
||||
direction: "se",
|
||||
deltaColumns: delta.x,
|
||||
deltaRows: delta.y,
|
||||
minWidth,
|
||||
minHeight,
|
||||
})
|
||||
: previewGridMove(transaction, {
|
||||
targetGridId: section.id,
|
||||
x: current.x + delta.x,
|
||||
y: current.y + delta.y,
|
||||
});
|
||||
const next = result.transaction.preview.grids[0]?.placements.find((candidate) => candidate.id === item.id);
|
||||
if (!result.accepted || !next || arePlacementsEqual(current, next)) {
|
||||
announce(`${accessibleLabel}: ${t("item.moveResize.keyboard.boundary")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const committed = commitGridTransaction(result.transaction).grids[0];
|
||||
if (!committed) return;
|
||||
keyboardGridRef.current = {
|
||||
...keyboardGridRef.current,
|
||||
current: committed.placements,
|
||||
};
|
||||
commitSectionGrid({ layoutId: currentLayoutId, sectionId: section.id, placements: committed.placements });
|
||||
announce(
|
||||
`${displayName}: ${t("item.moveResize.field.xOffset.label")} ${next.x + 1}, ${t(
|
||||
"item.moveResize.field.yOffset.label",
|
||||
)} ${next.y + 1}, ${t("item.moveResize.field.width.label")} ${next.w}, ${t(
|
||||
"item.moveResize.field.height.label",
|
||||
)} ${next.h}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
|
||||
if (isKeyboardEditing && event.target === event.currentTarget) {
|
||||
setKeyboardEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...semantics}
|
||||
className={combineClasses(isEditorActive ? classes.editorEntry : classes.staticItem)}
|
||||
style={isEditorActive ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
|
||||
data-grid-item-id={isEditorActive ? undefined : item.id}
|
||||
data-grid-item-type={isEditorActive ? undefined : item.type}
|
||||
data-editor-grid-entry={isEditorActive ? "true" : undefined}
|
||||
data-type={item.type}
|
||||
data-kind={item.kind}
|
||||
data-keyboard-editing={isEditorActive ? String(isKeyboardEditing) : undefined}
|
||||
aria-describedby={isEditorActive ? instructionsId : undefined}
|
||||
aria-keyshortcuts={
|
||||
isEditorActive
|
||||
? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown"
|
||||
: undefined
|
||||
}
|
||||
onBlur={isEditorActive ? handleBlur : undefined}
|
||||
onKeyDown={isEditorActive ? handleKeyboard : undefined}
|
||||
>
|
||||
{children}
|
||||
{isEditorActive && (
|
||||
<span id={instructionsId} className={classes.liveRegion}>
|
||||
{t("item.moveResize.keyboard.instructions")}
|
||||
</span>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const toPlacement = (item: ContainerSectionItem | SectionItem): SectionGridPlacement => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
x: item.xOffset,
|
||||
y: item.yOffset,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
});
|
||||
|
||||
const isArrowKey = (key: string): key is "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" =>
|
||||
key === "ArrowLeft" || key === "ArrowRight" || key === "ArrowUp" || key === "ArrowDown";
|
||||
|
||||
const getKeyboardDelta = (key: "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown") => ({
|
||||
x: key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0,
|
||||
y: key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0,
|
||||
});
|
||||
|
||||
const arePlacementsEqual = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
|
||||
|
||||
const arePlacementListsEqual = (first: readonly GridPlacement[], second: readonly GridPlacement[]) =>
|
||||
first.length === second.length &&
|
||||
first.every((placement, index) => {
|
||||
const candidate = second[index];
|
||||
return candidate?.id === placement.id && arePlacementsEqual(placement, candidate);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export const getGridDepth = (gridId: string, parentGridById: ReadonlyMap<string, string | null>) => {
|
||||
let depth = 0;
|
||||
let currentGridId = gridId;
|
||||
const visited = new Set([gridId]);
|
||||
|
||||
while (true) {
|
||||
const parentGridId = parentGridById.get(currentGridId);
|
||||
if (!parentGridId || visited.has(parentGridId)) return depth;
|
||||
|
||||
depth += 1;
|
||||
visited.add(parentGridId);
|
||||
currentGridId = parentGridId;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import type * as GridEditorModule from "./grid-editor";
|
||||
|
||||
let editorModulePromise: Promise<typeof GridEditorModule> | undefined;
|
||||
|
||||
export const loadGridEditorAsync = () => {
|
||||
editorModulePromise ??= import("./grid-editor").catch((error: unknown) => {
|
||||
editorModulePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return editorModulePromise;
|
||||
};
|
||||
|
||||
export const scheduleGridEditorWarmup = (loadAsync: () => Promise<unknown>) => {
|
||||
let animationFrameId: number | undefined;
|
||||
let idleCallbackId: number | undefined;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
let hydrationObserver: MutationObserver | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const load = () => {
|
||||
if (!cancelled) void loadAsync().catch(() => undefined);
|
||||
};
|
||||
const scheduleAfterPaint = () => {
|
||||
animationFrameId = window.requestAnimationFrame(() => {
|
||||
if ("requestIdleCallback" in window) {
|
||||
idleCallbackId = window.requestIdleCallback(load, { timeout: 2_000 });
|
||||
} else {
|
||||
timeoutId = setTimeout(load, 1_000);
|
||||
}
|
||||
});
|
||||
};
|
||||
const scheduleWhenInteractive = () => {
|
||||
if (document.querySelector('[data-board-hydrated="true"]')) {
|
||||
scheduleAfterPaint();
|
||||
return;
|
||||
}
|
||||
|
||||
hydrationObserver = new MutationObserver(() => {
|
||||
if (!document.querySelector('[data-board-hydrated="true"]')) return;
|
||||
hydrationObserver?.disconnect();
|
||||
hydrationObserver = undefined;
|
||||
scheduleAfterPaint();
|
||||
});
|
||||
hydrationObserver.observe(document.documentElement, {
|
||||
attributeFilter: ["data-board-hydrated"],
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
scheduleWhenInteractive();
|
||||
};
|
||||
|
||||
if (document.visibilityState === "visible") {
|
||||
scheduleWhenInteractive();
|
||||
} else {
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
hydrationObserver?.disconnect();
|
||||
if (animationFrameId !== undefined) window.cancelAnimationFrame(animationFrameId);
|
||||
if (idleCallbackId !== undefined && "cancelIdleCallback" in window) {
|
||||
window.cancelIdleCallback(idleCallbackId);
|
||||
}
|
||||
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export type GridEditorRuntimeStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
const GridEditorRuntimeContext = createContext<GridEditorRuntimeStatus>("idle");
|
||||
|
||||
export const GridEditorRuntimeProvider = ({
|
||||
children,
|
||||
status,
|
||||
}: PropsWithChildren<{ status: GridEditorRuntimeStatus }>) => (
|
||||
<GridEditorRuntimeContext.Provider value={status}>{children}</GridEditorRuntimeContext.Provider>
|
||||
);
|
||||
|
||||
export const useGridEditorRuntimeStatus = () => useContext(GridEditorRuntimeContext);
|
||||
@@ -0,0 +1,271 @@
|
||||
.board-grid-editor {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
overflow: visible;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.board-grid-entry {
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
transition:
|
||||
left 100ms ease,
|
||||
top 100ms ease,
|
||||
width 100ms ease,
|
||||
height 100ms ease;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-dnd-active="true"],
|
||||
.board-grid-entry[data-dnd-drag-source="true"] {
|
||||
z-index: 1000;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-dnd-drag-source="true"] {
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.board-grid-drag-overlay[data-dnd-dragging] {
|
||||
position: fixed !important;
|
||||
z-index: 10000;
|
||||
top: var(--dnd-top, 0) !important;
|
||||
left: var(--dnd-left, 0) !important;
|
||||
width: var(--dnd-width, auto);
|
||||
max-width: var(--dnd-width, auto);
|
||||
height: var(--dnd-height, auto);
|
||||
max-height: var(--dnd-height, auto);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
color: inherit;
|
||||
pointer-events: none !important;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
transform: var(--dnd-transform, none) !important;
|
||||
transition: var(--dnd-transition) !important;
|
||||
will-change: translate;
|
||||
}
|
||||
|
||||
.board-grid-drag-overlay[data-dnd-dragging]:not([data-dnd-dropping]) {
|
||||
translate: var(--dnd-translate) !important;
|
||||
}
|
||||
|
||||
.board-grid-drag-overlay[data-dnd-dragging][style*="--dnd-scale"] {
|
||||
scale: var(--dnd-scale) !important;
|
||||
transform-origin: var(--dnd-transform-origin) !important;
|
||||
}
|
||||
|
||||
.board-grid-drag-overlay-card {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
color: var(--mantine-color-text);
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 92%, transparent);
|
||||
border: 2px solid var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
box-shadow: var(--mantine-shadow-lg);
|
||||
zoom: var(--board-grid-overlay-content-zoom, 1);
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting="drag"] .board-grid-entry,
|
||||
body[data-board-grid-interacting="drag"] .board-grid-placeholder {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.board-grid-content-mount,
|
||||
.board-grid-portal-content,
|
||||
.board-grid-portal-content > [data-editor-grid-entry] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.board-grid-placeholder {
|
||||
z-index: 0;
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
|
||||
border: 2px dashed var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
left 100ms ease,
|
||||
top 100ms ease,
|
||||
width 100ms ease,
|
||||
height 100ms ease;
|
||||
}
|
||||
|
||||
.board-grid-placeholder[data-grid-placeholder-mode="resize"] {
|
||||
z-index: 1001;
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 10%, transparent);
|
||||
}
|
||||
|
||||
.board-grid-resize-outline {
|
||||
z-index: 1002;
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 6%, transparent);
|
||||
border: 2px solid var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.board-grid-resize-outline[data-grid-resize-valid="false"] {
|
||||
background: color-mix(in srgb, var(--mantine-color-red-filled) 8%, transparent);
|
||||
border-color: var(--mantine-color-red-filled);
|
||||
}
|
||||
|
||||
.board-grid-editor[data-dnd-drop-target="true"] {
|
||||
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
|
||||
outline-color: var(--mantine-color-red-filled);
|
||||
}
|
||||
|
||||
.board-grid-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: block;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-disabled="true"] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"] {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"]
|
||||
> .board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle::after {
|
||||
position: absolute;
|
||||
inset: 50% auto auto 50%;
|
||||
width: 12px;
|
||||
height: 3px;
|
||||
content: "";
|
||||
background: color-mix(in srgb, var(--mantine-color-text) 70%, transparent);
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.board-grid-entry:hover > .board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after,
|
||||
.board-grid-resize-handle:not([data-grid-resize-disabled="true"]):focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -8px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="n"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
right: 44px;
|
||||
left: 44px;
|
||||
height: 32px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="n"] {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
top: 44px;
|
||||
bottom: 44px;
|
||||
width: 32px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"] {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"]::after,
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"]::after {
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
z-index: 9;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
||||
top: 0;
|
||||
right: 0;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"] {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
top: 0;
|
||||
left: 0;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"]::after,
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"]::after {
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"]::after,
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"]::after {
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.board-grid-entry,
|
||||
.board-grid-placeholder,
|
||||
.board-grid-resize-outline,
|
||||
.board-grid-resize-handle::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { SectionContentItem } from "../content";
|
||||
import { SectionProvider } from "../section-context";
|
||||
import { useSectionItems } from "../use-section-items";
|
||||
import classes from "./section-grid.module.css";
|
||||
|
||||
interface GridPortalHostContextValue {
|
||||
announce: (message: string) => void;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
containers: ReadonlyMap<string, HTMLElement>;
|
||||
acquireContainer: (id: string) => HTMLElement;
|
||||
}
|
||||
|
||||
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* One portal registry owns every edit-mode item on the board. Its content
|
||||
* containers are stable even when drag-and-drop replaces or transfers the
|
||||
* outer positioning shell, so moving between grids does not remount widget
|
||||
* content.
|
||||
*/
|
||||
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
const board = useRequiredBoard();
|
||||
const { data: session } = useSession();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: Boolean(session) && isEditMode,
|
||||
});
|
||||
const containersRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const [containers, setContainers] = useState<Map<string, HTMLElement>>(() => new Map());
|
||||
const [announcement, setAnnouncement] = useState({ id: 0, message: "" });
|
||||
const announce = useCallback((message: string) => {
|
||||
setAnnouncement((previous) => ({ id: previous.id + 1, message }));
|
||||
}, []);
|
||||
const liveEntryIds = useMemo(
|
||||
() => new Set([...board.items.map((item) => item.id), ...board.sections.map((section) => section.id)]),
|
||||
[board.items, board.sections],
|
||||
);
|
||||
|
||||
const acquireContainer = useCallback((id: string) => {
|
||||
const existing = containersRef.current.get(id);
|
||||
if (existing) return existing;
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "board-grid-portal-content";
|
||||
container.dataset.boardGridPortal = id;
|
||||
containersRef.current.set(id, container);
|
||||
setContainers(new Map(containersRef.current));
|
||||
return container;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// A cross-grid move can briefly have no shell, but the entry still exists
|
||||
// on the board. Only remove containers whose board entity was deleted.
|
||||
let didPrune = false;
|
||||
for (const id of containersRef.current.keys()) {
|
||||
if (liveEntryIds.has(id)) continue;
|
||||
containersRef.current.delete(id);
|
||||
didPrune = true;
|
||||
}
|
||||
if (didPrune) setContainers(new Map(containersRef.current));
|
||||
}, [liveEntryIds]);
|
||||
|
||||
const value = useMemo<GridPortalHostContextValue>(
|
||||
() => ({
|
||||
announce,
|
||||
integrations,
|
||||
containers,
|
||||
acquireContainer,
|
||||
}),
|
||||
[acquireContainer, announce, containers, integrations],
|
||||
);
|
||||
|
||||
return (
|
||||
<GridPortalHostContext.Provider value={value}>
|
||||
{children}
|
||||
<div className={classes.liveRegion} aria-live="polite" aria-atomic="true">
|
||||
<span key={announcement.id}>{announcement.message}</span>
|
||||
</div>
|
||||
</GridPortalHostContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders stable portal content at this component's logical React position.
|
||||
* Keep it inside the editor runtime and dnd-kit providers so portaled entries
|
||||
* inherit the same keyboard, transaction, and nested-grid contexts as shells.
|
||||
*/
|
||||
export const BoardGridPortalRenderer = () => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { announce, containers, integrations } = useBoardGridPortalHost();
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from(containers, ([id, container]) => {
|
||||
const ownerSectionId = getOwnerSectionId(board, currentLayoutId, id);
|
||||
return ownerSectionId
|
||||
? createPortal(
|
||||
<GridPortalContent
|
||||
itemId={id}
|
||||
ownerSectionId={ownerSectionId}
|
||||
integrations={integrations}
|
||||
announce={announce}
|
||||
/>,
|
||||
container,
|
||||
id,
|
||||
)
|
||||
: null;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const useBoardGridPortalHost = () => {
|
||||
const context = useContext(GridPortalHostContext);
|
||||
if (!context) throw new Error("BoardGridPortalHost is required");
|
||||
return context;
|
||||
};
|
||||
|
||||
interface GridPortalContentProps {
|
||||
itemId: string;
|
||||
ownerSectionId: string;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
announce: (message: string) => void;
|
||||
}
|
||||
|
||||
const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: GridPortalContentProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { items, innerSections } = useSectionItems(ownerSectionId);
|
||||
const persistedEntry = [...items, ...innerSections].find((candidate) => candidate.id === itemId);
|
||||
const entry = persistedEntry;
|
||||
const rawSection = board.sections.find((section) => section.id === ownerSectionId);
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
const persistedSection = toGridSection(rawSection, currentLayoutId);
|
||||
const section = persistedSection;
|
||||
|
||||
if (!entry || !section || !currentLayout) return null;
|
||||
|
||||
const columnCount =
|
||||
section.kind === "container"
|
||||
? section.width
|
||||
: section.kind === "empty"
|
||||
? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
|
||||
: currentLayout.columnCount;
|
||||
const configuredMaxRowCount = section.kind === "container" ? section.height : null;
|
||||
const contentRowCount = Math.max(
|
||||
1,
|
||||
...items.map((item) => item.yOffset + item.height),
|
||||
...innerSections.map((innerSection) => innerSection.yOffset + innerSection.height),
|
||||
);
|
||||
const maxRowCount = configuredMaxRowCount === null ? null : Math.max(configuredMaxRowCount, contentRowCount);
|
||||
|
||||
return (
|
||||
<SectionProvider
|
||||
value={{
|
||||
section,
|
||||
items,
|
||||
innerSections,
|
||||
integrations,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
announce,
|
||||
}}
|
||||
>
|
||||
<SectionContentItem item={entry} integrations={integrations} />
|
||||
</SectionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const toGridSection = (
|
||||
section: Section | undefined,
|
||||
layoutId: string,
|
||||
): Exclude<Section, { kind: "container" }> | ContainerSectionItem | null => {
|
||||
if (!section) return null;
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) return null;
|
||||
|
||||
return {
|
||||
...section,
|
||||
...layout,
|
||||
type: "section",
|
||||
};
|
||||
};
|
||||
|
||||
const getOwnerSectionId = (board: ReturnType<typeof useRequiredBoard>, layoutId: string, entryId: string) => {
|
||||
const item = board.items.find((candidate) => candidate.id === entryId);
|
||||
if (item) {
|
||||
return item.layouts.find((layout) => layout.layoutId === layoutId)?.sectionId ?? null;
|
||||
}
|
||||
|
||||
const section = board.sections.find((candidate) => candidate.kind === "container" && candidate.id === entryId);
|
||||
if (!section || section.kind !== "container") return null;
|
||||
return section.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId ?? null;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
|
||||
export const getSectionGridPlacements = (board: Board, layoutId: string, sectionId: string): SectionGridPlacement[] => [
|
||||
...board.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout?.sectionId === sectionId
|
||||
? [
|
||||
{
|
||||
id: item.id,
|
||||
type: "item" as const,
|
||||
x: layout.xOffset,
|
||||
y: layout.yOffset,
|
||||
w: layout.width,
|
||||
h: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "container") return [];
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout?.parentSectionId === sectionId
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
type: "section" as const,
|
||||
x: layout.xOffset,
|
||||
y: layout.yOffset,
|
||||
w: layout.width,
|
||||
h: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
];
|
||||
|
||||
export const getSectionGridColumnCount = (board: Board, layoutId: string, sectionId: string): number | null => {
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) return null;
|
||||
if (section.kind === "container") {
|
||||
return section.layouts.find((layout) => layout.layoutId === layoutId)?.width ?? null;
|
||||
}
|
||||
|
||||
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
|
||||
return layout ? getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset)) : null;
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
.viewport {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.staticGrid,
|
||||
.editorGrid {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.editorGrid {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
}
|
||||
|
||||
.staticItem {
|
||||
overflow: visible;
|
||||
transition:
|
||||
left 220ms ease,
|
||||
top 220ms ease,
|
||||
width 220ms ease,
|
||||
height 220ms ease;
|
||||
}
|
||||
|
||||
.editorEntry {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.editorEntry:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.editorEntry:focus-visible,
|
||||
.editorEntry[data-keyboard-editing="true"] {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.editorLoading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editorLoadingItem {
|
||||
background-color: var(--mantine-color-default);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.liveRegion {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.staticItem {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Box, LoadingOverlay } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import {
|
||||
getCollapsedDisplayLayout,
|
||||
getEditableCanvasAttributes,
|
||||
getLayoutRowCount,
|
||||
getLogicalItemStyle,
|
||||
getLogicalTrackSize,
|
||||
getReadonlyCanvasAttributes,
|
||||
normalizeGridPlacement,
|
||||
} from "~/components/board/layout";
|
||||
import { loadGridEditorAsync } from "./grid-editor-loader";
|
||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
import { SectionContent } from "../content";
|
||||
import { useCollapsedSectionIds } from "../section-collapse";
|
||||
import { SectionProvider, useSectionContext } from "../section-context";
|
||||
import { useSectionItems } from "../use-section-items";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import classes from "./section-grid.module.css";
|
||||
|
||||
const GridEditor = dynamic(loadGridEditorAsync, {
|
||||
loading: GridEditorLoading,
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
function GridEditorLoading() {
|
||||
const t = useI18n();
|
||||
const { items, innerSections } = useSectionContext();
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="output"
|
||||
className={classes.editorLoading}
|
||||
data-testid="board-grid-editor-loading"
|
||||
aria-label={t("common.action.loading")}
|
||||
>
|
||||
{[...items, ...innerSections].map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
className={classes.editorLoadingItem}
|
||||
style={getLogicalItemStyle({
|
||||
x: item.xOffset,
|
||||
y: item.yOffset,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
})}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
<LoadingOverlay visible />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionGridProps {
|
||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
||||
columnCount: number;
|
||||
requestedRowCount?: number;
|
||||
label: string;
|
||||
railPlacement?: "main" | "left" | "right";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SectionGrid = ({
|
||||
section,
|
||||
columnCount,
|
||||
requestedRowCount = 0,
|
||||
label,
|
||||
railPlacement = "main",
|
||||
className,
|
||||
}: SectionGridProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const editorRuntimeStatus = useGridEditorRuntimeStatus();
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { items, innerSections } = useSectionItems(section.id);
|
||||
const { announce, integrations } = useBoardGridPortalHost();
|
||||
const collapsedSectionIds = useCollapsedSectionIds();
|
||||
const minimumBySectionId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
innerSections.map((innerSection) => [
|
||||
innerSection.id,
|
||||
getContainerMinimumSize(board, currentLayoutId, innerSection.id),
|
||||
]),
|
||||
),
|
||||
[board, currentLayoutId, innerSections],
|
||||
);
|
||||
|
||||
const placements = useMemo(
|
||||
() =>
|
||||
[...items, ...innerSections].map((item): SectionGridPlacement => {
|
||||
const minimum = item.type === "section" ? minimumBySectionId.get(item.id) : undefined;
|
||||
return normalizeGridPlacement(
|
||||
{
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
x: item.xOffset,
|
||||
y: item.yOffset,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
minW: minimum?.width,
|
||||
minH: minimum?.height,
|
||||
},
|
||||
columnCount,
|
||||
);
|
||||
}),
|
||||
[columnCount, innerSections, items, minimumBySectionId],
|
||||
);
|
||||
|
||||
const displayPlacements = useMemo(() => {
|
||||
const directCollapsedIds = new Set(
|
||||
placements
|
||||
.filter(
|
||||
(placement) =>
|
||||
placement.type === "section" &&
|
||||
collapsedSectionIds.has(placement.id) &&
|
||||
innerSections.find((innerSection) => innerSection.id === placement.id)?.options.collapsible,
|
||||
)
|
||||
.map((placement) => placement.id),
|
||||
);
|
||||
if (directCollapsedIds.size === 0) return placements;
|
||||
|
||||
return getCollapsedDisplayLayout(placements, {
|
||||
columnCount,
|
||||
collapsedItemIds: directCollapsedIds,
|
||||
});
|
||||
}, [collapsedSectionIds, columnCount, innerSections, placements]);
|
||||
|
||||
const placementById = useMemo(
|
||||
() => new Map(displayPlacements.map((placement) => [placement.id, placement])),
|
||||
[displayPlacements],
|
||||
);
|
||||
const displayedItems = items.map((item) => withPlacement(item, placementById.get(item.id)));
|
||||
const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id)));
|
||||
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
|
||||
const rowCount = Math.max(contentRowCount, requestedRowCount);
|
||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||
const logicalHeight = getLogicalTrackSize(rowCount);
|
||||
const canvasAttributes = isEditMode
|
||||
? getEditableCanvasAttributes({ label, columnCount, rowCount })
|
||||
: getReadonlyCanvasAttributes({ label });
|
||||
|
||||
return (
|
||||
<SectionProvider
|
||||
value={{
|
||||
section,
|
||||
items: displayedItems,
|
||||
innerSections: displayedInnerSections,
|
||||
integrations,
|
||||
columnCount,
|
||||
maxRowCount: section.kind === "container" ? rowCount : null,
|
||||
announce,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
{...canvasAttributes}
|
||||
className={combineClasses(classes.viewport, className)}
|
||||
w={logicalWidth}
|
||||
h={`var(--board-grid-drag-height, ${logicalHeight}px)`}
|
||||
data-section-id={section.id}
|
||||
data-section-kind={section.kind}
|
||||
data-rail-placement={railPlacement}
|
||||
>
|
||||
{isEditMode && editorRuntimeStatus === "ready" ? (
|
||||
<GridEditor
|
||||
sectionId={section.id}
|
||||
columnCount={columnCount}
|
||||
rowCount={rowCount}
|
||||
placements={displayPlacements}
|
||||
className={combineClasses("board-grid-editor", classes.editorGrid)}
|
||||
/>
|
||||
) : isEditMode && editorRuntimeStatus === "loading" ? (
|
||||
<GridEditorLoading />
|
||||
) : (
|
||||
<Box
|
||||
className={classes.staticGrid}
|
||||
style={{ width: logicalWidth, height: logicalHeight }}
|
||||
data-grid-section-id={section.id}
|
||||
data-kind={section.kind}
|
||||
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
|
||||
>
|
||||
<SectionContent />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</SectionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const getContainerMinimumSize = (
|
||||
board: ReturnType<typeof useRequiredBoard>,
|
||||
layoutId: string,
|
||||
sectionId: string,
|
||||
visited = new Set<string>(),
|
||||
): { width: number; height: number } => {
|
||||
if (visited.has(sectionId)) return { width: 1, height: 1 };
|
||||
const nextVisited = new Set(visited).add(sectionId);
|
||||
const directItems: PlacementBounds[] = board.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout?.sectionId === sectionId ? [layout] : [];
|
||||
});
|
||||
const directSections: PlacementBounds[] = board.sections.flatMap((candidate) => {
|
||||
if (candidate.kind !== "container") return [];
|
||||
const layout = candidate.layouts.find((candidateLayout) => candidateLayout.layoutId === layoutId);
|
||||
if (!layout || layout.parentSectionId !== sectionId) return [];
|
||||
const minimum = getContainerMinimumSize(board, layoutId, candidate.id, nextVisited);
|
||||
return [
|
||||
{
|
||||
...layout,
|
||||
width: Math.max(layout.width, minimum.width),
|
||||
height: Math.max(layout.height, minimum.height),
|
||||
},
|
||||
];
|
||||
});
|
||||
const children = [...directItems, ...directSections];
|
||||
|
||||
return {
|
||||
width: Math.max(1, ...children.map((child) => child.xOffset + child.width)),
|
||||
height: Math.max(1, ...children.map((child) => child.yOffset + child.height)),
|
||||
};
|
||||
};
|
||||
|
||||
interface PlacementBounds {
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const withPlacement = <
|
||||
TItem extends {
|
||||
id: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
>(
|
||||
item: TItem,
|
||||
placement: SectionGridPlacement | undefined,
|
||||
): TItem => {
|
||||
if (!placement) return item;
|
||||
return {
|
||||
...item,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,321 @@
|
||||
import { act, createElement } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { getCollapsedDisplayLayout, LOGICAL_GRID_CELL_SIZE } from "../../../layout";
|
||||
import { FixedGridItem } from "../fixed-grid-item";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
announce: vi.fn(),
|
||||
commitSectionGrid: vi.fn(),
|
||||
editMode: true,
|
||||
maxRowCount: null as number | null,
|
||||
items: [
|
||||
{
|
||||
id: "weather",
|
||||
type: "item" as const,
|
||||
kind: "weather" as const,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
advancedOptions: {
|
||||
title: null as string | null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clock",
|
||||
type: "item" as const,
|
||||
kind: "clock" as const,
|
||||
xOffset: 1,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
advancedOptions: {
|
||||
title: null as string | null,
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/edit-mode", () => ({
|
||||
useEditMode: () => [mocks.editMode],
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/context", () => ({
|
||||
useCurrentLayout: () => "layout",
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string, values?: Record<string, string>) =>
|
||||
key === "item.moveResize.entryLabel" ? `${values?.name}, column ${values?.column}, row ${values?.row}` : key,
|
||||
}));
|
||||
|
||||
vi.mock("../use-grid-layout-actions", () => ({
|
||||
useGridLayoutActions: () => ({
|
||||
commitSectionGrid: mocks.commitSectionGrid,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../grid-editor-runtime", () => ({
|
||||
useGridEditorRuntimeStatus: () => "ready",
|
||||
}));
|
||||
|
||||
vi.mock("../../section-context", () => ({
|
||||
useSectionContext: () => ({
|
||||
section: { id: "root", kind: "empty", options: {} },
|
||||
items: mocks.items,
|
||||
innerSections: [],
|
||||
columnCount: 3,
|
||||
maxRowCount: mocks.maxRowCount,
|
||||
announce: mocks.announce,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/core", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
return {
|
||||
Box: ({ children, ...props }: { children?: ReactNode }) => React.createElement("div", props, children),
|
||||
};
|
||||
});
|
||||
|
||||
describe("fixed grid item behavior", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.announce.mockReset();
|
||||
mocks.commitSectionGrid.mockReset();
|
||||
mocks.editMode = true;
|
||||
mocks.maxRowCount = null;
|
||||
getWeatherMock().advancedOptions.title = null;
|
||||
getClockMock().xOffset = 1;
|
||||
getClockMock().yOffset = 0;
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test("moves with the keyboard and swaps an equal-sized destination", () => {
|
||||
renderWeather(root);
|
||||
|
||||
const entry = getEditorEntry(container);
|
||||
startKeyboardEditing(entry);
|
||||
act(() => entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
|
||||
|
||||
expect(mocks.commitSectionGrid).toHaveBeenCalledWith({
|
||||
layoutId: "layout",
|
||||
sectionId: "root",
|
||||
placements: [
|
||||
{ id: "weather", type: "item", x: 1, y: 0, w: 1, h: 1 },
|
||||
{ id: "clock", type: "item", x: 0, y: 0, w: 1, h: 1 },
|
||||
],
|
||||
});
|
||||
expect(mocks.announce).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("uses shift plus an arrow to resize while honoring grid bounds", () => {
|
||||
renderWeather(root);
|
||||
|
||||
const entry = getEditorEntry(container);
|
||||
startKeyboardEditing(entry);
|
||||
act(() =>
|
||||
entry.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "ArrowRight",
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(mocks.commitSectionGrid).toHaveBeenCalledWith({
|
||||
layoutId: "layout",
|
||||
sectionId: "root",
|
||||
placements: [
|
||||
{ id: "weather", type: "item", x: 0, y: 0, w: 2, h: 1 },
|
||||
{ id: "clock", type: "item", x: 1, y: 1, w: 1, h: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
mocks.commitSectionGrid.mockReset();
|
||||
act(() =>
|
||||
entry.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "ArrowLeft",
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(mocks.commitSectionGrid).toHaveBeenCalledWith({
|
||||
layoutId: "layout",
|
||||
sectionId: "root",
|
||||
placements: [
|
||||
{ id: "weather", type: "item", x: 0, y: 0, w: 1, h: 1 },
|
||||
{ id: "clock", type: "item", x: 1, y: 1, w: 1, h: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
mocks.commitSectionGrid.mockReset();
|
||||
act(() =>
|
||||
entry.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "ArrowLeft",
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(mocks.commitSectionGrid).not.toHaveBeenCalled();
|
||||
expect(mocks.announce).toHaveBeenLastCalledWith(
|
||||
"widget.weather.name, column 1, row 1: item.moveResize.keyboard.boundary",
|
||||
);
|
||||
});
|
||||
|
||||
test("applies repeated keyboard resizes before board props rerender", () => {
|
||||
renderWeather(root);
|
||||
|
||||
const entry = getEditorEntry(container);
|
||||
startKeyboardEditing(entry);
|
||||
act(() => {
|
||||
entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", shiftKey: true, bubbles: true }));
|
||||
entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", shiftKey: true, bubbles: true }));
|
||||
});
|
||||
|
||||
expect(mocks.commitSectionGrid).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.commitSectionGrid).toHaveBeenLastCalledWith({
|
||||
layoutId: "layout",
|
||||
sectionId: "root",
|
||||
placements: [
|
||||
{ id: "weather", type: "item", x: 0, y: 0, w: 1, h: 3 },
|
||||
{ id: "clock", type: "item", x: 1, y: 0, w: 1, h: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("stops keyboard editing when the rendered grid state is invalid", () => {
|
||||
getClockMock().xOffset = 0;
|
||||
renderWeather(root);
|
||||
|
||||
const entry = getEditorEntry(container);
|
||||
startKeyboardEditing(entry);
|
||||
act(() => entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
|
||||
|
||||
expect(entry.dataset.keyboardEditing).toBe("false");
|
||||
expect(mocks.commitSectionGrid).not.toHaveBeenCalled();
|
||||
expect(mocks.announce).toHaveBeenLastCalledWith(
|
||||
"widget.weather.name, column 1, row 1: item.moveResize.keyboard.stopped",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the whole tile as the focusable move surface without extra drag chrome", () => {
|
||||
getWeatherMock().advancedOptions.title = " Balcony forecast ";
|
||||
renderWeather(root);
|
||||
|
||||
const entry = getEditorEntry(container);
|
||||
|
||||
expect(entry.getAttribute("aria-label")).toBe("Balcony forecast, column 1, row 1");
|
||||
expect(entry.tabIndex).toBe(0);
|
||||
expect(entry.getAttribute("aria-describedby")).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="board-grid-drag-affordance"]')).toBeNull();
|
||||
});
|
||||
|
||||
test("renders a view-only 1x1 card at the fixed 200px logical size", () => {
|
||||
mocks.editMode = false;
|
||||
renderWeather(root);
|
||||
|
||||
const item = container.querySelector<HTMLElement>('[data-grid-id="weather"]');
|
||||
expect(item).not.toBeNull();
|
||||
expect(item?.style.width).toBe(`${LOGICAL_GRID_CELL_SIZE}px`);
|
||||
expect(item?.style.height).toBe(`${LOGICAL_GRID_CELL_SIZE}px`);
|
||||
expect(item?.getAttribute("role")).toBeNull();
|
||||
|
||||
act(() => item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
|
||||
expect(mocks.commitSectionGrid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not move or resize beyond a fixed section row boundary", () => {
|
||||
mocks.maxRowCount = 1;
|
||||
renderWeather(root);
|
||||
|
||||
const entry = getEditorEntry(container);
|
||||
startKeyboardEditing(entry);
|
||||
act(() => entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
|
||||
act(() => entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", shiftKey: true, bubbles: true })));
|
||||
|
||||
expect(mocks.commitSectionGrid).not.toHaveBeenCalled();
|
||||
expect(mocks.announce).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"widget.weather.name, column 1, row 1: item.moveResize.keyboard.boundary",
|
||||
);
|
||||
expect(mocks.announce).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"widget.weather.name, column 1, row 1: item.moveResize.keyboard.boundary",
|
||||
);
|
||||
});
|
||||
|
||||
test("collapsing an expanded box changes display geometry without mutating persisted placement", () => {
|
||||
const persisted = [
|
||||
{ id: "box", type: "section" as const, x: 0, y: 0, w: 2, h: 4 },
|
||||
{ id: "app", type: "item" as const, x: 0, y: 4, w: 1, h: 1 },
|
||||
];
|
||||
|
||||
const displayed = getCollapsedDisplayLayout(persisted, {
|
||||
columnCount: 2,
|
||||
collapsedItemIds: new Set(["box"]),
|
||||
});
|
||||
|
||||
expect(displayed).toEqual([
|
||||
{ ...persisted[0], h: 0.5 },
|
||||
{ ...persisted[1], y: 0.5 },
|
||||
]);
|
||||
expect(persisted[0]?.h).toBe(4);
|
||||
expect(persisted[1]?.y).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
const renderWeather = (root: Root) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(
|
||||
FixedGridItem,
|
||||
{
|
||||
item: getWeatherMock() as never,
|
||||
},
|
||||
createElement("span", null, "Weather"),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const getWeatherMock = () => {
|
||||
const item = mocks.items[0];
|
||||
if (!item) throw new Error("Expected the weather item mock to exist");
|
||||
return item;
|
||||
};
|
||||
|
||||
const getClockMock = () => {
|
||||
const item = mocks.items[1];
|
||||
if (!item) throw new Error("Expected the clock item mock to exist");
|
||||
return item;
|
||||
};
|
||||
|
||||
const getEditorEntry = (container: HTMLElement) => {
|
||||
const entry = container.querySelector<HTMLDivElement>("[data-editor-grid-entry]");
|
||||
if (!entry) throw new Error("Expected the fixed grid item to render an editor entry");
|
||||
|
||||
return entry;
|
||||
};
|
||||
|
||||
const startKeyboardEditing = (entry: HTMLDivElement) => {
|
||||
act(() => entry.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })));
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getGridDepth } from "../grid-depth";
|
||||
|
||||
describe("grid depth", () => {
|
||||
test("follows the registered parent chain for deeply nested grids", () => {
|
||||
const parents = new Map<string, string | null>([
|
||||
["root", null],
|
||||
["first", "root"],
|
||||
["second", "first"],
|
||||
["third", "second"],
|
||||
]);
|
||||
|
||||
expect(getGridDepth("root", parents)).toBe(0);
|
||||
expect(getGridDepth("third", parents)).toBe(3);
|
||||
});
|
||||
|
||||
test("stops safely when malformed parent data contains a cycle", () => {
|
||||
const parents = new Map<string, string | null>([
|
||||
["first", "second"],
|
||||
["second", "first"],
|
||||
]);
|
||||
|
||||
expect(getGridDepth("first", parents)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { scheduleGridEditorWarmup } from "../grid-editor-loader";
|
||||
|
||||
describe("grid editor warm-up", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
test("waits for board hydration, a paint, and browser idle time", async () => {
|
||||
let animationFrameCallback: FrameRequestCallback | undefined;
|
||||
let idleCallback: IdleRequestCallback | undefined;
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
animationFrameCallback = callback;
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => {
|
||||
idleCallback = callback;
|
||||
return 2;
|
||||
});
|
||||
vi.stubGlobal("cancelIdleCallback", vi.fn());
|
||||
const loadAsync = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const cancel = scheduleGridEditorWarmup(loadAsync);
|
||||
expect(animationFrameCallback).toBeUndefined();
|
||||
|
||||
const canvas = document.createElement("section");
|
||||
canvas.dataset.boardHydrated = "true";
|
||||
document.body.append(canvas);
|
||||
await vi.waitFor(() => expect(animationFrameCallback).toBeDefined());
|
||||
expect(loadAsync).not.toHaveBeenCalled();
|
||||
|
||||
animationFrameCallback?.(0);
|
||||
expect(idleCallback).toBeDefined();
|
||||
expect(loadAsync).not.toHaveBeenCalled();
|
||||
|
||||
idleCallback?.({
|
||||
didTimeout: false,
|
||||
timeRemaining: () => 10,
|
||||
});
|
||||
expect(loadAsync).toHaveBeenCalledOnce();
|
||||
cancel();
|
||||
});
|
||||
|
||||
test("does not load after cancellation", () => {
|
||||
let idleCallback: IdleRequestCallback | undefined;
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => {
|
||||
idleCallback = callback;
|
||||
return 2;
|
||||
});
|
||||
vi.stubGlobal("cancelIdleCallback", vi.fn());
|
||||
document.body.innerHTML = '<section data-board-hydrated="true"></section>';
|
||||
const loadAsync = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const cancel = scheduleGridEditorWarmup(loadAsync);
|
||||
cancel();
|
||||
idleCallback?.({ didTimeout: false, timeRemaining: () => 10 });
|
||||
|
||||
expect(loadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("falls back to a timeout when requestIdleCallback is unavailable", async () => {
|
||||
vi.useFakeTimers();
|
||||
const requestIdleCallbackDescriptor = Object.getOwnPropertyDescriptor(window, "requestIdleCallback");
|
||||
Reflect.deleteProperty(window, "requestIdleCallback");
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
document.body.innerHTML = '<section data-board-hydrated="true"></section>';
|
||||
const loadAsync = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const cancel = scheduleGridEditorWarmup(loadAsync);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(loadAsync).toHaveBeenCalledOnce();
|
||||
cancel();
|
||||
vi.useRealTimers();
|
||||
if (requestIdleCallbackDescriptor) {
|
||||
Object.defineProperty(window, "requestIdleCallback", requestIdleCallbackDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
test("waits until the document becomes visible", () => {
|
||||
let visibilityState: DocumentVisibilityState = "hidden";
|
||||
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibilityState);
|
||||
const animationFrame = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(1);
|
||||
const loadAsync = vi.fn().mockResolvedValue(undefined);
|
||||
document.body.innerHTML = '<section data-board-hydrated="true"></section>';
|
||||
|
||||
const cancel = scheduleGridEditorWarmup(loadAsync);
|
||||
expect(animationFrame).not.toHaveBeenCalled();
|
||||
|
||||
visibilityState = "visible";
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(animationFrame).toHaveBeenCalledOnce();
|
||||
cancel();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { relative, resolve } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const gridDirectory = resolve(import.meta.dirname, "..");
|
||||
const nextjsSourceDirectory = resolve(gridDirectory, "../../../../");
|
||||
const sectionGridPath = resolve(gridDirectory, "section-grid.tsx");
|
||||
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
|
||||
|
||||
describe("board grid import boundary", () => {
|
||||
test("keeps every current dnd-kit runtime import inside the edit-only module", () => {
|
||||
const runtimeImports = getSourceFiles(nextjsSourceDirectory)
|
||||
.flatMap(getRuntimeImports)
|
||||
.filter(({ specifier }) => isCurrentDndKitImport(specifier))
|
||||
.map(({ filePath, kind, specifier }) => ({
|
||||
file: toSourceRelativePath(filePath),
|
||||
kind,
|
||||
specifier,
|
||||
}))
|
||||
.toSorted((first, second) =>
|
||||
`${first.file}:${first.specifier}:${first.kind}`.localeCompare(
|
||||
`${second.file}:${second.specifier}:${second.kind}`,
|
||||
),
|
||||
);
|
||||
|
||||
expect(runtimeImports).toEqual([
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/abstract" },
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/collision" },
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom" },
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom/utilities" },
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/react" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("loads the editor through next/dynamic only in the edit branch", () => {
|
||||
const source = readFileSync(sectionGridPath, "utf8");
|
||||
|
||||
expect(source).toMatch(/import dynamic from ["']next\/dynamic["']/);
|
||||
expect(source).toMatch(
|
||||
/const GridEditor = dynamic\(loadGridEditorAsync,\s*{[\s\S]*?loading:\s*GridEditorLoading,[\s\S]*?ssr:\s*false,[\s\S]*?}\);/,
|
||||
);
|
||||
expect(source.match(/<GridEditor\b/g)).toHaveLength(1);
|
||||
expect(source).toMatch(/isEditMode\s*&&\s*editorRuntimeStatus\s*===\s*["']ready["']\s*\?\s*\(\s*<GridEditor\b/);
|
||||
expect(getRuntimeImports(sectionGridPath).filter(({ specifier }) => specifier === "./grid-editor")).toEqual([]);
|
||||
expect(
|
||||
getRuntimeImports(editorLoaderPath)
|
||||
.filter(({ specifier }) => specifier === "./grid-editor")
|
||||
.map(({ kind }) => kind),
|
||||
).toEqual(["dynamic"]);
|
||||
});
|
||||
});
|
||||
|
||||
const CURRENT_DND_KIT_PACKAGES = ["@dnd-kit/abstract", "@dnd-kit/collision", "@dnd-kit/dom", "@dnd-kit/react"];
|
||||
|
||||
const isCurrentDndKitImport = (specifier: string) =>
|
||||
CURRENT_DND_KIT_PACKAGES.some((packageName) => specifier === packageName || specifier.startsWith(`${packageName}/`));
|
||||
|
||||
interface RuntimeImport {
|
||||
filePath: string;
|
||||
kind: "dynamic" | "require" | "static";
|
||||
specifier: string;
|
||||
}
|
||||
|
||||
const getSourceFiles = (directory: string): string[] =>
|
||||
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = resolve(directory, entry.name);
|
||||
if (entry.isDirectory()) return getSourceFiles(path);
|
||||
return /\.[cm]?[jt]sx?$/.test(entry.name) ? [path] : [];
|
||||
});
|
||||
|
||||
const getRuntimeImports = (filePath: string): RuntimeImport[] => {
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
const imports: RuntimeImport[] = [];
|
||||
const addMatches = (pattern: RegExp, kind: RuntimeImport["kind"], specifierGroup: number) => {
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const specifier = match[specifierGroup];
|
||||
if (specifier) imports.push({ filePath, kind, specifier });
|
||||
}
|
||||
};
|
||||
|
||||
addMatches(/(?:^|\n)\s*(?:import|export)\s+(?!type\b)[^;]*?\sfrom\s*["']([^"']+)["']/g, "static", 1);
|
||||
addMatches(/(?:^|\n)\s*import\s*["']([^"']+)["']/g, "static", 1);
|
||||
addMatches(/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, "dynamic", 1);
|
||||
addMatches(/\brequire\s*\(\s*["']([^"']+)["']\s*\)/g, "require", 1);
|
||||
|
||||
return imports;
|
||||
};
|
||||
|
||||
const toSourceRelativePath = (filePath: string) => relative(nextjsSourceDirectory, filePath).replaceAll("\\", "/");
|
||||
@@ -0,0 +1,104 @@
|
||||
import { act, createElement, useLayoutEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { BoardGridPortalHost, useBoardGridPortalHost } from "../grid-portal-host";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
board: {
|
||||
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "root" }] }],
|
||||
sections: [],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/api/client", () => ({
|
||||
clientApi: {
|
||||
integration: {
|
||||
all: {
|
||||
useQuery: () => ({ data: undefined }),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth/client", () => ({
|
||||
useSession: () => ({ data: { user: { id: "user" } } }),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/context", () => ({
|
||||
useCurrentLayout: () => "desktop",
|
||||
useRequiredBoard: () => mocks.board,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/edit-mode", () => ({
|
||||
useEditMode: () => [true],
|
||||
}));
|
||||
|
||||
describe("board grid portal host", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.board = {
|
||||
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "root" }] }],
|
||||
sections: [],
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test("keeps a portal across relocation and prunes it after permanent deletion", () => {
|
||||
const acquired: HTMLElement[] = [];
|
||||
|
||||
renderHost(root, true, 0, acquired);
|
||||
expect(getContainerCount(container)).toBe("1");
|
||||
|
||||
mocks.board = {
|
||||
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "other" }] }],
|
||||
sections: [],
|
||||
};
|
||||
renderHost(root, true, 1, acquired);
|
||||
expect(getContainerCount(container)).toBe("1");
|
||||
expect(new Set(acquired).size).toBe(1);
|
||||
|
||||
mocks.board = { items: [], sections: [] };
|
||||
renderHost(root, false, 2, acquired);
|
||||
expect(getContainerCount(container)).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
const renderHost = (root: Root, acquire: boolean, revision: number, acquired: HTMLElement[]) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(BoardGridPortalHost, null, createElement(PortalRegistryProbe, { acquire, revision, acquired })),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const PortalRegistryProbe = ({
|
||||
acquire,
|
||||
revision,
|
||||
acquired,
|
||||
}: {
|
||||
acquire: boolean;
|
||||
revision: number;
|
||||
acquired: HTMLElement[];
|
||||
}) => {
|
||||
const portalHost = useBoardGridPortalHost();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (acquire) acquired.push(portalHost.acquireContainer("weather"));
|
||||
}, [acquire, acquired, portalHost, revision]);
|
||||
|
||||
return createElement("span", { "data-container-count": String(portalHost.containers.size) });
|
||||
};
|
||||
|
||||
const getContainerCount = (container: HTMLElement) =>
|
||||
container.querySelector<HTMLElement>("[data-container-count]")?.dataset.containerCount;
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
|
||||
export interface SectionGridPlacement extends GridPlacement {
|
||||
type: "item" | "section";
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
}
|
||||
|
||||
export interface CommitSectionGridInput {
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
placements: readonly SectionGridPlacement[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits complete section snapshots in one cache update. One drag or resize
|
||||
* can move several neighbours and can cross section boundaries, so the whole
|
||||
* transaction must remain atomic.
|
||||
*/
|
||||
export const useGridLayoutActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const commitSectionGrids = useCallback(
|
||||
(snapshots: readonly CommitSectionGridInput[]) => {
|
||||
const byId = new Map(
|
||||
snapshots.flatMap(({ layoutId, sectionId, placements }) =>
|
||||
placements.map((placement) => [`${layoutId}:${placement.id}`, { layoutId, sectionId, placement }] as const),
|
||||
),
|
||||
);
|
||||
updateBoard((previous) => {
|
||||
let hasChanges = false;
|
||||
const items = previous.items.map((item) => {
|
||||
const layouts = item.layouts.map((layout) => {
|
||||
const resolved = byId.get(`${layout.layoutId}:${item.id}`);
|
||||
if (!resolved || resolved.placement.type !== "item") return layout;
|
||||
const { placement, sectionId } = resolved;
|
||||
if (
|
||||
layout.sectionId === sectionId &&
|
||||
layout.xOffset === placement.x &&
|
||||
layout.yOffset === placement.y &&
|
||||
layout.width === placement.w &&
|
||||
layout.height === placement.h
|
||||
) {
|
||||
return layout;
|
||||
}
|
||||
|
||||
return {
|
||||
...layout,
|
||||
sectionId,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
};
|
||||
});
|
||||
|
||||
if (!layouts.some((layout, index) => layout !== item.layouts[index])) return item;
|
||||
hasChanges = true;
|
||||
return { ...item, layouts };
|
||||
});
|
||||
const sections = previous.sections.map((innerSection) => {
|
||||
if (innerSection.kind !== "container") return innerSection;
|
||||
const layouts = innerSection.layouts.map((layout) => {
|
||||
const resolved = byId.get(`${layout.layoutId}:${innerSection.id}`);
|
||||
if (!resolved || resolved.placement.type !== "section") return layout;
|
||||
const { placement, sectionId } = resolved;
|
||||
if (
|
||||
layout.parentSectionId === sectionId &&
|
||||
layout.xOffset === placement.x &&
|
||||
layout.yOffset === placement.y &&
|
||||
layout.width === placement.w &&
|
||||
layout.height === placement.h
|
||||
) {
|
||||
return layout;
|
||||
}
|
||||
|
||||
return {
|
||||
...layout,
|
||||
parentSectionId: sectionId,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
};
|
||||
});
|
||||
|
||||
if (!layouts.some((layout, index) => layout !== innerSection.layouts[index])) return innerSection;
|
||||
hasChanges = true;
|
||||
return { ...innerSection, layouts };
|
||||
});
|
||||
|
||||
return hasChanges ? { ...previous, items, sections } : previous;
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const commitSectionGrid = useCallback(
|
||||
(snapshot: CommitSectionGridInput) => {
|
||||
commitSectionGrids([snapshot]);
|
||||
},
|
||||
[commitSectionGrids],
|
||||
);
|
||||
|
||||
return { commitSectionGrid, commitSectionGrids };
|
||||
};
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
.suppressTouchCallout {
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
-226
@@ -1,226 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { KeyboardEvent, MouseEvent, PointerEvent, PropsWithChildren } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { BoxProps } from "@mantine/core";
|
||||
import { Box, Menu } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import { ItemSelectModal } from "../../items/item-select-modal";
|
||||
import { useBoardPermissions } from "../../permissions/client";
|
||||
import type { BoardPlacementHint } from "../../items/actions/create-item";
|
||||
import type { UseGridstackRefs } from "./use-gridstack";
|
||||
import classes from "./board-empty-space-context-menu.module.css";
|
||||
import { hasExceededLongPressMoveTolerance } from "./long-press";
|
||||
|
||||
const suppressTouchCalloutClass = classes.suppressTouchCallout as string;
|
||||
|
||||
interface MenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
placement: BoardPlacementHint;
|
||||
}
|
||||
|
||||
interface Props extends BoxProps {
|
||||
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
|
||||
refs: UseGridstackRefs;
|
||||
}
|
||||
|
||||
export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProps }: PropsWithChildren<Props>) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const settings = useSettings();
|
||||
const t = useI18n();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const { openModal } = useModalAction(ItemSelectModal);
|
||||
const [isEditMode] = useEditMode();
|
||||
const [menu, setMenu] = useState<MenuState | null>(null);
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const longPressPointer = useRef<{ pointerId: number; x: number; y: number } | null>(null);
|
||||
const touchGuardElement = useRef<HTMLDivElement | null>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const enabled = hasChangeAccess && settings.enableRightClickOnWidgets;
|
||||
const closeMenu = (afterFocusRestore?: (returnFocus: HTMLElement | null) => void) => {
|
||||
const returnFocus = returnFocusRef.current;
|
||||
returnFocusRef.current = null;
|
||||
setMenu(null);
|
||||
requestAnimationFrame(() => {
|
||||
returnFocus?.focus();
|
||||
afterFocusRestore?.(returnFocus);
|
||||
});
|
||||
};
|
||||
const clearLongPress = () => {
|
||||
if (longPressTimer.current !== null) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
touchGuardElement.current?.classList.remove(suppressTouchCalloutClass);
|
||||
touchGuardElement.current = null;
|
||||
longPressPointer.current = null;
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (longPressTimer.current !== null) clearTimeout(longPressTimer.current);
|
||||
touchGuardElement.current?.classList.remove(suppressTouchCalloutClass);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const resolvePlacement = (target: EventTarget | null, pageX: number, pageY: number) => {
|
||||
const wrapper = refs.wrapperElement.current;
|
||||
const gridstack = refs.gridstack.current;
|
||||
if (!(target instanceof Element) || !wrapper || !gridstack) return null;
|
||||
if (target.closest(".grid-stack") !== wrapper || target.closest(".grid-stack-item")) return null;
|
||||
|
||||
const cell = gridstack.getCellFromPixel({ left: pageX, top: pageY }, true);
|
||||
const rowLimit = section.kind === "dynamic" ? section.height : undefined;
|
||||
if (
|
||||
cell.x < 0 ||
|
||||
cell.y < 0 ||
|
||||
cell.x >= gridstack.getColumn() ||
|
||||
(rowLimit !== undefined && cell.y >= rowLimit) ||
|
||||
!gridstack.isAreaEmpty(cell.x, cell.y, 1, 1)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { sectionId: section.id, layoutId: currentLayoutId, xOffset: cell.x, yOffset: cell.y };
|
||||
};
|
||||
|
||||
const handleContextMenu = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (!enabled) return;
|
||||
const placement = resolvePlacement(event.target, event.pageX, event.pageY);
|
||||
if (!placement) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
returnFocusRef.current = event.currentTarget;
|
||||
setMenu({ x: event.clientX, y: event.clientY, placement });
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!enabled || event.pointerType !== "touch") return;
|
||||
const placement = resolvePlacement(event.target, event.pageX, event.pageY);
|
||||
if (!placement) return;
|
||||
const returnFocus = event.currentTarget;
|
||||
clearLongPress();
|
||||
longPressPointer.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
|
||||
touchGuardElement.current = event.currentTarget;
|
||||
touchGuardElement.current.classList.add(suppressTouchCalloutClass);
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
returnFocusRef.current = returnFocus;
|
||||
setMenu({ x: event.clientX, y: event.clientY, placement });
|
||||
navigator.vibrate?.(20);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const pointer = longPressPointer.current;
|
||||
if (
|
||||
!pointer ||
|
||||
pointer.pointerId !== event.pointerId ||
|
||||
!hasExceededLongPressMoveTolerance(pointer, { x: event.clientX, y: event.clientY })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
clearLongPress();
|
||||
};
|
||||
|
||||
const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (longPressPointer.current?.pointerId === event.pointerId) clearLongPress();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!enabled ||
|
||||
event.currentTarget !== event.target ||
|
||||
(event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const wrapper = event.currentTarget;
|
||||
const gridstack = refs.gridstack.current;
|
||||
if (!gridstack) return;
|
||||
|
||||
const rowCount = Math.max(1, section.kind === "dynamic" ? section.height : gridstack.getRow() + 1);
|
||||
for (let yOffset = 0; yOffset < rowCount; yOffset++) {
|
||||
for (let xOffset = 0; xOffset < gridstack.getColumn(); xOffset++) {
|
||||
if (!gridstack.isAreaEmpty(xOffset, yOffset, 1, 1)) continue;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
event.preventDefault();
|
||||
returnFocusRef.current = wrapper;
|
||||
setMenu({
|
||||
x: rect.left + 24,
|
||||
y: rect.top + 24,
|
||||
placement: { sectionId: section.id, layoutId: currentLayoutId, xOffset, yOffset },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
{...boxProps}
|
||||
ref={refs.wrapper}
|
||||
tabIndex={enabled ? 0 : undefined}
|
||||
aria-label={enabled ? t("item.create.addAtPosition") : undefined}
|
||||
onContextMenu={handleContextMenu}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onPointerLeave={handlePointerEnd}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
<Menu
|
||||
opened={menu !== null}
|
||||
withInitialFocusPlaceholder={false}
|
||||
onChange={(opened) => {
|
||||
if (!opened) closeMenu();
|
||||
}}
|
||||
withinPortal
|
||||
position="bottom-start"
|
||||
returnFocus={false}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Box pos="fixed" left={menu?.x ?? 0} top={menu?.y ?? 0} w={1} h={1} style={{ pointerEvents: "none" }} />
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
data-autofocus
|
||||
leftSection={<IconPlus size={18} />}
|
||||
onClick={() => {
|
||||
const placement = menu?.placement;
|
||||
closeMenu((returnFocus) => {
|
||||
if (!placement) return;
|
||||
openModal(
|
||||
{ placement, board, persistImmediately: !isEditMode },
|
||||
{
|
||||
onClose: () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (returnFocus?.isConnected) returnFocus.focus();
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("item.create.addAtPosition")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { BoxProps } from "@mantine/core";
|
||||
import { Box } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import type { SectionKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { GridItemHTMLElement } from "@homarr/gridstack";
|
||||
|
||||
interface Props extends BoxProps {
|
||||
id: string;
|
||||
type: "item" | "section";
|
||||
kind: WidgetKind | SectionKind;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
|
||||
}
|
||||
|
||||
export const GridStackItem = ({
|
||||
id,
|
||||
type,
|
||||
kind,
|
||||
xOffset,
|
||||
yOffset,
|
||||
width,
|
||||
height,
|
||||
minWidth = 1,
|
||||
minHeight = 1,
|
||||
innerRef,
|
||||
children,
|
||||
...boxProps
|
||||
}: PropsWithChildren<Props>) => {
|
||||
useEffect(() => {
|
||||
if (!innerRef?.current?.gridstackNode) return;
|
||||
if (type !== "section") return;
|
||||
innerRef.current.gridstackNode.minW = minWidth;
|
||||
innerRef.current.gridstackNode.minH = minHeight;
|
||||
}, [minWidth, minHeight, innerRef, type]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...boxProps}
|
||||
className={combineClasses("grid-stack-item", boxProps.className)}
|
||||
data-id={id}
|
||||
data-type={type}
|
||||
data-kind={kind}
|
||||
gs-x={xOffset}
|
||||
gs-y={yOffset}
|
||||
gs-w={width}
|
||||
gs-h={height}
|
||||
gs-min-w={minWidth}
|
||||
gs-min-h={minHeight}
|
||||
ref={innerRef as React.RefObject<HTMLDivElement>}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { BoxProps } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
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 { BoardEmptySpaceContextMenu } from "./board-empty-space-context-menu";
|
||||
import { useGridstack } from "./use-gridstack";
|
||||
|
||||
interface Props extends BoxProps {
|
||||
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
|
||||
}
|
||||
|
||||
export const GridStack = ({ section, ...props }: Props) => {
|
||||
const { items, innerSections } = useSectionItems(section.id);
|
||||
const itemIds = [...items, ...innerSections].map((item) => item.id);
|
||||
|
||||
const { refs } = useGridstack(section, itemIds);
|
||||
|
||||
return (
|
||||
<SectionProvider value={{ section, items, innerSections, refs }}>
|
||||
<BoardEmptySpaceContextMenu
|
||||
{...props}
|
||||
section={section}
|
||||
refs={refs}
|
||||
data-kind={section.kind}
|
||||
data-section-id={section.id}
|
||||
className={combineClasses(`grid-stack grid-stack-${section.kind}`, props.className)}
|
||||
>
|
||||
<SectionContent />
|
||||
</BoardEmptySpaceContextMenu>
|
||||
</SectionProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { RefObject } from "react";
|
||||
|
||||
import type { GridItemHTMLElement } from "@homarr/gridstack";
|
||||
import { GridStack } from "@homarr/gridstack";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
|
||||
interface InitializeGridstackProps {
|
||||
section: Omit<Section, "items">;
|
||||
itemIds: string[];
|
||||
refs: {
|
||||
wrapper: RefObject<HTMLDivElement | null>;
|
||||
items: RefObject<Record<string, RefObject<GridItemHTMLElement | null>>>;
|
||||
gridstack: RefObject<GridStack | null>;
|
||||
};
|
||||
sectionColumnCount: number;
|
||||
}
|
||||
|
||||
export const initializeGridstack = ({ section, itemIds, refs, sectionColumnCount }: InitializeGridstackProps) => {
|
||||
if (!refs.wrapper.current) return false;
|
||||
// initialize gridstack
|
||||
const newGrid = refs.gridstack;
|
||||
newGrid.current = GridStack.init(
|
||||
{
|
||||
column: sectionColumnCount,
|
||||
margin: 10,
|
||||
cellHeight: 128,
|
||||
float: true,
|
||||
alwaysShowResizeHandle: true,
|
||||
acceptWidgets: true,
|
||||
staticGrid: true,
|
||||
minRow: section.kind === "dynamic" && "height" in section ? (section.height as number) : 1,
|
||||
maxRow: section.kind === "dynamic" && "height" in section ? (section.height as number) : 0,
|
||||
animate: false,
|
||||
styleInHead: true,
|
||||
disableRemoveNodeOnDrop: true,
|
||||
},
|
||||
// selector of the gridstack item (it's eather category or wrapper)
|
||||
`.grid-stack-${section.kind}[data-section-id='${section.id}']`,
|
||||
);
|
||||
const grid = newGrid.current;
|
||||
|
||||
// Must be used to update the column count after the initialization
|
||||
grid.column(sectionColumnCount, "none");
|
||||
|
||||
grid.batchUpdate();
|
||||
grid.removeAll(false);
|
||||
itemIds.forEach((id) => {
|
||||
const ref = refs.items.current[id]?.current;
|
||||
if (!ref) return;
|
||||
|
||||
grid.makeWidget(ref);
|
||||
});
|
||||
grid.batchUpdate(false);
|
||||
return true;
|
||||
};
|
||||
Loaded 100 of 940 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user