feat: rebuild board grid with dnd-kit
This commit is contained in:
1 parent
5bde4d3cc5
commit
1f5311e93b
107 files changed
+5628
-3209
No files matched your search
@@ -76,11 +76,8 @@ const getEmptySectionPosition = (
|
||||
if (!boardLayout) return;
|
||||
|
||||
const sectionElements = getSectionElements(board, { sectionId: section.id, layoutId: layout.layoutId });
|
||||
if (section.kind !== "dynamic") {
|
||||
const columnCount =
|
||||
section.kind === "empty"
|
||||
? getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset))
|
||||
: boardLayout.columnCount;
|
||||
if (section.kind === "empty") {
|
||||
const columnCount = getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset));
|
||||
|
||||
const size = {
|
||||
width: layout.width,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("item actions create-item", () => {
|
||||
);
|
||||
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();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getMoveTargets } from "../../item-move-modal";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
|
||||
import { ContainerSectionMockBuilder } from "./mocks/container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "./mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
|
||||
@@ -20,8 +20,8 @@ describe("item move targets", () => {
|
||||
new EmptySectionMockBuilder({ id: "canvas", xOffset: 0 }).build(),
|
||||
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
|
||||
new EmptySectionMockBuilder({ id: "right-gutter", xOffset: 1 }).build(),
|
||||
new DynamicSectionMockBuilder({
|
||||
id: "dynamic",
|
||||
new ContainerSectionMockBuilder({
|
||||
id: "container",
|
||||
options: {
|
||||
title: "",
|
||||
customCssClasses: [],
|
||||
@@ -53,7 +53,7 @@ describe("item move targets", () => {
|
||||
},
|
||||
{
|
||||
canvas: "Dashboard canvas",
|
||||
dynamicSection: "New section",
|
||||
container: "New section",
|
||||
leftRail: "Left dashboard rail",
|
||||
rightRail: "Right dashboard rail",
|
||||
numbered: (name, index) => `${name}, destination ${index}`,
|
||||
@@ -78,7 +78,7 @@ describe("item move targets", () => {
|
||||
layout.columnCount = 5;
|
||||
layout.leftGutterColumnCount = 2;
|
||||
|
||||
const section = new DynamicSectionMockBuilder({ id: "source" })
|
||||
const section = new ContainerSectionMockBuilder({ id: "source" })
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
parentSectionId: "canvas",
|
||||
@@ -110,7 +110,7 @@ describe("item move targets", () => {
|
||||
{ ...sourceLayout, id: section.id, type: "section" },
|
||||
{
|
||||
canvas: "Canvas",
|
||||
dynamicSection: "Section",
|
||||
container: "Section",
|
||||
leftRail: "Left",
|
||||
rightRail: "Right",
|
||||
numbered: (name, index) => `${name} ${index}`,
|
||||
|
||||
@@ -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";
|
||||
@@ -58,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 {
|
||||
|
||||
@@ -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,40 +0,0 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
type DynamicSectionMock = Omit<Partial<DynamicSection>, "options"> & {
|
||||
options?: Partial<DynamicSection["options"]>;
|
||||
};
|
||||
|
||||
export class DynamicSectionMockBuilder {
|
||||
private readonly section: DynamicSection;
|
||||
|
||||
constructor(section?: DynamicSectionMock) {
|
||||
this.section = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
layouts: [],
|
||||
...section,
|
||||
collapsed: section?.collapsed ?? false,
|
||||
options: dynamicSectionOptionsSchema.parse(section?.options),
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,8 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) =>
|
||||
: [];
|
||||
}),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "dynamic") return [];
|
||||
if (section.kind !== "container") return [];
|
||||
const sectionLayout = section.layouts.find(
|
||||
(candidate) => candidate.layoutId === layoutId && candidate.parentSectionId === root.id,
|
||||
);
|
||||
@@ -75,7 +75,7 @@ export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) =>
|
||||
board.sections
|
||||
.filter(
|
||||
(section) =>
|
||||
section.kind === "dynamic" &&
|
||||
section.kind === "container" &&
|
||||
section.collapsed &&
|
||||
section.options.collapsible &&
|
||||
placements.some((placement) => placement.id === section.id),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
@@ -10,6 +10,10 @@ 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;
|
||||
@@ -84,7 +88,7 @@ export const ScaledBoardCanvas = ({
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-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";
|
||||
@@ -51,13 +51,13 @@ describe("board lanes", () => {
|
||||
const layoutId = "layout";
|
||||
const mainSectionId = "main";
|
||||
const leftSectionId = "left";
|
||||
const dynamicSectionId = "dynamic";
|
||||
const containerSectionId = "container";
|
||||
const board = new BoardMockBuilder()
|
||||
.addSection(new EmptySectionMockBuilder({ id: mainSectionId }).build())
|
||||
.addSection(new EmptySectionMockBuilder({ id: leftSectionId, xOffset: -1 }).build())
|
||||
.addSection(
|
||||
new DynamicSectionMockBuilder({
|
||||
id: dynamicSectionId,
|
||||
new ContainerSectionMockBuilder({
|
||||
id: containerSectionId,
|
||||
collapsed: true,
|
||||
options: { collapsible: true },
|
||||
})
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets/definition";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const filterByItemKind = async <TKind extends WidgetKind>(
|
||||
items: Item[],
|
||||
settings: SettingsContextProps,
|
||||
kind: TKind,
|
||||
) => {
|
||||
const definition = await loadWidgetDefinition(kind);
|
||||
|
||||
return items
|
||||
.filter((item) => item.kind === kind)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
options: reduceWidgetOptionsWithDefinition(
|
||||
definition,
|
||||
settings,
|
||||
item.options,
|
||||
) as WidgetComponentProps<TKind>["options"],
|
||||
}));
|
||||
};
|
||||
+27
-20
@@ -1,31 +1,31 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { ActionIcon, Badge, Box, Card } from "@mantine/core";
|
||||
import { IconChevronDown, IconChevronUp, IconExternalLink } from "@tabler/icons-react";
|
||||
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 { DynamicSectionItem } from "~/app/[locale]/boards/_types";
|
||||
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 BoardDynamicSectionMenu = dynamic(
|
||||
() => import("./dynamic/dynamic-menu").then((module) => module.BoardDynamicSectionMenu),
|
||||
const BoardContainerMenu = dynamic(
|
||||
() => import("./container/container-menu").then((module) => module.BoardContainerMenu),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface Props {
|
||||
section: DynamicSectionItem;
|
||||
section: ContainerSectionItem;
|
||||
}
|
||||
|
||||
export const BoardDynamicSection = ({ section }: Props) => {
|
||||
export const BoardContainerSection = ({ section }: Props) => {
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const t = useScopedI18n("section.dynamic");
|
||||
const t = useScopedI18n("section.container");
|
||||
const tAll = useI18n();
|
||||
const options = section.options;
|
||||
const { open: openAllInNewTabs, isLoading: areAppsLoading } = useOpenSectionApps(
|
||||
@@ -37,22 +37,14 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
||||
collapsible: options.collapsible,
|
||||
});
|
||||
const label = options.title || t("action.create");
|
||||
const contentId = `board-section-${section.id}-content`;
|
||||
const labelLeft = !isEditMode && options.collapsible ? 40 : 10;
|
||||
const contentId = `board-container-${section.id}-content`;
|
||||
const labelLeft = isEditMode || options.collapsible ? 40 : 10;
|
||||
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="grid-stack-item-content"
|
||||
data-grid-item-content
|
||||
w="100%"
|
||||
h="100%"
|
||||
style={{
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
<Box className="board-grid-item-content" data-grid-item-content w="100%" h="100%" style={{ overflow: "visible" }}>
|
||||
<Card
|
||||
className={combineClasses(classes.itemCard, section.options.customCssClasses.join(" "))}
|
||||
className={combineClasses(classes.itemCard, options.customCssClasses.join(" "))}
|
||||
w="100%"
|
||||
h="100%"
|
||||
styles={{
|
||||
@@ -65,6 +57,21 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
||||
radius={board.itemRadius}
|
||||
p={0}
|
||||
>
|
||||
{isEditMode && (
|
||||
<ActionIcon
|
||||
component="span"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
left={4}
|
||||
style={{ zIndex: 10, cursor: "grab", touchAction: "none" }}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
data-grid-container-drag-handle
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IconGripVertical size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{options.showLabel && options.title && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
@@ -123,7 +130,7 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
{isEditMode && <BoardDynamicSectionMenu section={section} />}
|
||||
{isEditMode && <BoardContainerMenu section={section} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+10
-12
@@ -1,14 +1,14 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import type { Board, DynamicSection, DynamicSectionLayout, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
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 addDynamicSectionCallback = () => (board: Board) => {
|
||||
export const addContainerCallback = () => (board: Board) => {
|
||||
const firstSection = board.sections
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
@@ -18,22 +18,22 @@ export const addDynamicSectionCallback = () => (board: Board) => {
|
||||
|
||||
if (!firstSection) return board;
|
||||
|
||||
const sectionLayouts = createDynamicSectionLayouts(board, firstSection);
|
||||
const sectionLayouts = createContainerLayouts(board, firstSection);
|
||||
const newSection = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
kind: "container",
|
||||
collapsed: false,
|
||||
options: dynamicSectionOptionsSchema.parse(undefined),
|
||||
options: containerSectionOptionsSchema.parse(undefined),
|
||||
layouts: sectionLayouts,
|
||||
} satisfies DynamicSection;
|
||||
} satisfies ContainerSection;
|
||||
|
||||
return {
|
||||
...board,
|
||||
sections: board.sections.concat(newSection as unknown as DynamicSection),
|
||||
sections: board.sections.concat(newSection),
|
||||
};
|
||||
};
|
||||
|
||||
const createDynamicSectionLayouts = (board: Board, currentSection: EmptySection): DynamicSectionLayout[] => {
|
||||
const createContainerLayouts = (board: Board, currentSection: EmptySection): ContainerSectionLayout[] => {
|
||||
const layouts = getBoardLayouts(board);
|
||||
|
||||
return layouts.map((layoutId) => {
|
||||
@@ -49,9 +49,7 @@ const createDynamicSectionLayouts = (board: Board, currentSection: EmptySection)
|
||||
? getFirstEmptyPosition(elements, columnCount, undefined, size)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) {
|
||||
throw new Error("Your board is full");
|
||||
}
|
||||
if (!emptyPosition) throw new Error("Your board is full");
|
||||
|
||||
return {
|
||||
...size,
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
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().addSection(removed).addSection(child).addItem(nestedItem).build();
|
||||
|
||||
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 and resolves collisions while flattening malformed child 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: 0, width: 2, height: 1 });
|
||||
expect(layouts.b).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 1, width: 2, height: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board, ContainerSection } from "~/app/[locale]/boards/_types";
|
||||
import { resolvePinnedGridCollisions } from "~/components/board/sections/grid/dnd";
|
||||
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 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) throw new Error("Layout not found");
|
||||
|
||||
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) throw new Error("Layout not found");
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
sectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
return resolveFlattenedLayouts(flattened, sectionToRemove, board);
|
||||
};
|
||||
|
||||
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 pinnedId = movedIds.toSorted()[0];
|
||||
if (!pinnedId) continue;
|
||||
|
||||
const columnCount = getParentColumnCount(board, removedLayout.layoutId, removedLayout.parentSectionId);
|
||||
if (!columnCount) continue;
|
||||
const placements = getSectionPlacements(board, removedLayout.layoutId, removedLayout.parentSectionId);
|
||||
const pinned = placements.find((placement) => placement.id === pinnedId);
|
||||
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;
|
||||
}),
|
||||
},
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const getSectionPlacements = (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,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
];
|
||||
|
||||
const getParentColumnCount = (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((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,43 @@
|
||||
import { useCallback } from "react";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { containerSectionOptionsSchema } 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: z.infer<typeof containerSectionOptionsSchema>;
|
||||
}
|
||||
|
||||
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 };
|
||||
};
|
||||
+8
-9
@@ -7,18 +7,17 @@ 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";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
interface ModalProps {
|
||||
value: z.infer<typeof dynamicSectionOptionsSchema>;
|
||||
onSuccessfulEdit: (value: z.infer<typeof dynamicSectionOptionsSchema>) => void;
|
||||
value: z.infer<typeof containerSectionOptionsSchema>;
|
||||
onSuccessfulEdit: (value: z.infer<typeof containerSectionOptionsSchema>) => void;
|
||||
}
|
||||
|
||||
export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, innerProps }) => {
|
||||
export const ContainerEditModal = createModal<ModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const form = useZodForm(dynamicSectionOptionsSchema.unwrap(), {
|
||||
const form = useZodForm(containerSectionOptionsSchema.unwrap(), {
|
||||
mode: "controlled",
|
||||
initialValues: { ...innerProps.value },
|
||||
});
|
||||
@@ -31,7 +30,7 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label={t("section.dynamic.option.title.label")} data-autofocus {...form.getInputProps("title")} />
|
||||
<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")}
|
||||
@@ -48,11 +47,11 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
|
||||
{...form.getInputProps("showOpenAll", { type: "checkbox" })}
|
||||
/>
|
||||
<TextMultiSelect
|
||||
label={t("section.dynamic.option.customCssClasses.label")}
|
||||
label={t("section.container.option.customCssClasses.label")}
|
||||
{...form.getInputProps("customCssClasses")}
|
||||
/>
|
||||
<ColorInput
|
||||
label={t("section.dynamic.option.borderColor.label")}
|
||||
label={t("section.container.option.borderColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
rightSection={
|
||||
+17
-29
@@ -5,46 +5,39 @@ 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 type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useOpenItemMoveModal } from "../../items/item-move-modal";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { useDynamicSectionActions } from "./dynamic-actions";
|
||||
import { DynamicSectionEditModal } from "./dynamic-edit-modal";
|
||||
import { useContainerActions } from "./container-actions";
|
||||
import { ContainerEditModal } from "./container-edit-modal";
|
||||
|
||||
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionItem }) => {
|
||||
export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem }) => {
|
||||
const t = useI18n();
|
||||
const tDynamic = useScopedI18n("section.dynamic");
|
||||
const tContainer = useScopedI18n("section.container");
|
||||
const tItem = useScopedI18n("item");
|
||||
const { openModal } = useModalAction(DynamicSectionEditModal);
|
||||
const { openModal } = useModalAction(ContainerEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { updateDynamicSection, removeDynamicSection } = useDynamicSectionActions();
|
||||
const { updateContainer, removeContainer } = useContainerActions();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { section: parentSection } = useSectionContext();
|
||||
const label = section.options.title || tDynamic("action.create");
|
||||
const menuRightOffset = parentSection.kind === "dynamic" ? 44 : 4;
|
||||
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) => {
|
||||
updateDynamicSection({
|
||||
itemId: section.id,
|
||||
newOptions: options,
|
||||
});
|
||||
},
|
||||
onSuccessfulEdit: (options) => updateContainer({ containerId: section.id, newOptions: options }),
|
||||
});
|
||||
};
|
||||
|
||||
const openRemoveModal = () => {
|
||||
openConfirmModal({
|
||||
title: tDynamic("remove.title"),
|
||||
children: tDynamic("remove.message"),
|
||||
onConfirm: () => {
|
||||
removeDynamicSection({ id: section.id });
|
||||
},
|
||||
title: tContainer("remove.title"),
|
||||
children: tContainer("remove.message"),
|
||||
onConfirm: () => removeContainer({ id: section.id }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -53,14 +46,14 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius={"xl"}
|
||||
radius="xl"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
right={menuRightOffset}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
<IconLayoutKanban size={"1rem"} />
|
||||
<IconLayoutKanban size="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
@@ -70,19 +63,14 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconArrowsMove size={16} />}
|
||||
onClick={() =>
|
||||
openMoveModal({
|
||||
entry: section,
|
||||
sourceSectionId: section.parentSectionId,
|
||||
})
|
||||
}
|
||||
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}>
|
||||
{tDynamic("action.remove")}
|
||||
{tContainer("action.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
@@ -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,47 +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.kind !== "dynamic" ? item : { ...item, options: newOptions },
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeDynamicSection = useCallback(
|
||||
(input: RemoveDynamicSectionInput) => {
|
||||
updateBoard(removeDynamicSectionCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return {
|
||||
addDynamicSection,
|
||||
updateDynamicSection,
|
||||
removeDynamicSection,
|
||||
};
|
||||
};
|
||||
@@ -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,86 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import { 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 };
|
||||
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,
|
||||
};
|
||||
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(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("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,58 @@
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
|
||||
interface RectangleLike {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface DragProjectionOrigin {
|
||||
initialPointer: { x: number; y: number };
|
||||
initialShape: { left: number; top: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
|
||||
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 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: Math.round((current.x - initial.x) / (LOGICAL_GRID_PITCH * scale.x)),
|
||||
y: Math.round((current.y - initial.y) / (LOGICAL_GRID_PITCH * scale.y)),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,743 @@
|
||||
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("uses pinned reflow for a partial overlap even when sizes match", () => {
|
||||
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: 1, y: 0, w: 2, h: 1 });
|
||||
expect(getPlacement(moved.preview, "target")).toMatchObject({ x: 2, y: 1, 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,497 @@
|
||||
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 exact-slot
|
||||
* drops swap items 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 displaced = grid.placements.find(
|
||||
(placement) => placement.id !== active.id && placement.x === nextActive.x && placement.y === nextActive.y,
|
||||
);
|
||||
|
||||
const swapped = displaced
|
||||
? grid.placements.map((placement) => {
|
||||
if (placement.id === active.id) return { ...placement, x: nextActive.x, y: nextActive.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,25 @@
|
||||
export {
|
||||
beginGridTransaction,
|
||||
cancelGridTransaction,
|
||||
commitGridTransaction,
|
||||
previewGridMove,
|
||||
previewGridResize,
|
||||
resolvePinnedGridCollisions,
|
||||
} from "./engine";
|
||||
export { getPointerProjectedShape, getSnappedGridCoordinates, getSnappedGridDelta } from "./coordinates";
|
||||
export type { DragProjectionOrigin } from "./coordinates";
|
||||
export type {
|
||||
BeginGridTransactionInput,
|
||||
GridMoveGuard,
|
||||
GridMoveGuardContext,
|
||||
GridMoveGuardResult,
|
||||
GridMoveInput,
|
||||
GridMoveOptions,
|
||||
GridOperationRejection,
|
||||
GridPreviewResult,
|
||||
GridResizeDirection,
|
||||
GridResizeInput,
|
||||
GridTransaction,
|
||||
TransactionalGrid,
|
||||
TransactionalGridState,
|
||||
} from "./types";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEvent, KeyboardEvent, PropsWithChildren } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
@@ -9,26 +9,31 @@ import { useCurrentLayout } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import {
|
||||
getEditableGridCellAttributes,
|
||||
getLayoutRowCount,
|
||||
getLogicalItemStyle,
|
||||
getReadonlyGridItemAttributes,
|
||||
placeGridItem,
|
||||
} from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import classes from "./section-grid.module.css";
|
||||
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } 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: DynamicSectionItem | SectionItem;
|
||||
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,
|
||||
@@ -36,22 +41,35 @@ export const FixedGridItem = ({
|
||||
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.dynamic.untitled");
|
||||
: 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);
|
||||
@@ -61,7 +79,7 @@ export const FixedGridItem = ({
|
||||
};
|
||||
|
||||
const handleKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget || !isEditMode || event.altKey || event.ctrlKey || event.metaKey) {
|
||||
if (event.target !== event.currentTarget || !isEditorActive || event.altKey || event.ctrlKey || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
@@ -77,27 +95,60 @@ export const FixedGridItem = ({
|
||||
if (!isKeyboardEditing || !isArrowKey(event.key)) return;
|
||||
|
||||
event.preventDefault();
|
||||
const allPlacements = [...items, ...innerSections].map(toPlacement);
|
||||
const current = allPlacements.find((candidate) => candidate.id === item.id);
|
||||
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;
|
||||
|
||||
const next = getKeyboardPlacement(current, event.key, event.shiftKey, {
|
||||
columnCount,
|
||||
minWidth,
|
||||
minHeight,
|
||||
maxRowCount,
|
||||
});
|
||||
if (next.x === current.x && next.y === current.y && next.w === current.w && next.h === current.h) {
|
||||
const transaction = beginGridTransaction(
|
||||
{
|
||||
grids: [
|
||||
{
|
||||
id: section.id,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
placements,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ activeId: current.id, sourceGridId: section.id },
|
||||
);
|
||||
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 resolved = placeGridItem(allPlacements, next, columnCount) as SectionGridPlacement[];
|
||||
if (maxRowCount !== null && getLayoutRowCount(resolved) > maxRowCount) {
|
||||
announce(`${accessibleLabel}: ${t("item.moveResize.keyboard.boundary")}`);
|
||||
return;
|
||||
}
|
||||
commitSectionGrid({ layoutId: currentLayoutId, sectionId: section.id, placements: resolved });
|
||||
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",
|
||||
@@ -108,38 +159,36 @@ export const FixedGridItem = ({
|
||||
};
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
|
||||
if (isKeyboardEditing && !event.currentTarget.contains(event.relatedTarget)) {
|
||||
setIsKeyboardEditing(false);
|
||||
if (isKeyboardEditing && event.target === event.currentTarget) {
|
||||
setKeyboardEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const semantics = isEditMode
|
||||
? getEditableGridCellAttributes({ label: accessibleLabel, placement })
|
||||
: getReadonlyGridItemAttributes(placement);
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...semantics}
|
||||
className={combineClasses(isEditMode ? classes.editorEntry : classes.staticItem)}
|
||||
style={isEditMode ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
|
||||
data-grid-item-id={isEditMode ? undefined : item.id}
|
||||
data-grid-item-type={isEditMode ? undefined : item.type}
|
||||
data-editor-grid-entry={isEditMode ? "true" : undefined}
|
||||
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={isEditMode ? String(isKeyboardEditing) : undefined}
|
||||
aria-describedby={isEditMode ? instructionsId : undefined}
|
||||
data-keyboard-editing={isEditorActive ? String(isKeyboardEditing) : undefined}
|
||||
aria-describedby={isEditorActive ? instructionsId : undefined}
|
||||
aria-keyshortcuts={
|
||||
isEditMode
|
||||
isEditorActive
|
||||
? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown"
|
||||
: undefined
|
||||
}
|
||||
onBlur={isEditMode ? handleBlur : undefined}
|
||||
onKeyDown={isEditMode ? handleKeyboard : undefined}
|
||||
onBlur={isEditorActive ? handleBlur : undefined}
|
||||
onKeyDown={isEditorActive ? handleKeyboard : undefined}
|
||||
>
|
||||
{isEditMode && <span className={classes.dragAffordance} data-testid="board-grid-drag-affordance" aria-hidden />}
|
||||
{isEditorActive && (
|
||||
<span className={classes.dragAffordance} data-testid="board-grid-drag-affordance" aria-hidden />
|
||||
)}
|
||||
{children}
|
||||
{isEditMode && (
|
||||
{isEditorActive && (
|
||||
<span id={instructionsId} className={classes.liveRegion}>
|
||||
{t("item.moveResize.keyboard.instructions")}
|
||||
</span>
|
||||
@@ -148,7 +197,7 @@ export const FixedGridItem = ({
|
||||
);
|
||||
};
|
||||
|
||||
const toPlacement = (item: DynamicSectionItem | SectionItem): SectionGridPlacement => ({
|
||||
const toPlacement = (item: ContainerSectionItem | SectionItem): SectionGridPlacement => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
x: item.xOffset,
|
||||
@@ -160,43 +209,17 @@ const toPlacement = (item: DynamicSectionItem | SectionItem): SectionGridPlaceme
|
||||
const isArrowKey = (key: string): key is "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" =>
|
||||
key === "ArrowLeft" || key === "ArrowRight" || key === "ArrowUp" || key === "ArrowDown";
|
||||
|
||||
const getKeyboardPlacement = (
|
||||
current: SectionGridPlacement,
|
||||
key: "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown",
|
||||
resize: boolean,
|
||||
{
|
||||
columnCount,
|
||||
minWidth,
|
||||
minHeight,
|
||||
maxRowCount,
|
||||
}: {
|
||||
columnCount: number;
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
maxRowCount: number | null;
|
||||
},
|
||||
): GridPlacement & Pick<SectionGridPlacement, "type"> => {
|
||||
if (resize) {
|
||||
const widthDelta = key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0;
|
||||
const heightDelta = key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0;
|
||||
return {
|
||||
...current,
|
||||
w: Math.max(minWidth, Math.min(columnCount - current.x, current.w + widthDelta)),
|
||||
h: Math.max(
|
||||
minHeight,
|
||||
Math.min(maxRowCount === null ? Number.MAX_SAFE_INTEGER : maxRowCount - current.y, current.h + heightDelta),
|
||||
),
|
||||
};
|
||||
}
|
||||
const getKeyboardDelta = (key: "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown") => ({
|
||||
x: key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0,
|
||||
y: key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0,
|
||||
});
|
||||
|
||||
const xDelta = key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0;
|
||||
const yDelta = key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0;
|
||||
return {
|
||||
...current,
|
||||
x: Math.max(0, Math.min(columnCount - current.w, current.x + xDelta)),
|
||||
y: Math.max(
|
||||
0,
|
||||
Math.min(maxRowCount === null ? Number.MAX_SAFE_INTEGER : maxRowCount - current.h, current.y + yDelta),
|
||||
),
|
||||
};
|
||||
};
|
||||
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);
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
|
||||
/**
|
||||
* GridStack's floating mode lets Homarr keep intentional empty rows, but it
|
||||
* disables GridStack's built-in swap path. Restore the intuitive subset: when
|
||||
* an item lands exactly in an equally-sized item's former slot, exchange the
|
||||
* two slots and leave every other item where it started.
|
||||
*/
|
||||
export const getDropSwapPlacements = (
|
||||
initial: readonly SectionGridPlacement[],
|
||||
current: readonly SectionGridPlacement[],
|
||||
movedItemId: string,
|
||||
bounds: { columnCount: number; rowCount?: number },
|
||||
): SectionGridPlacement[] | null => {
|
||||
const movedInitially = initial.find((placement) => placement.id === movedItemId);
|
||||
const movedCurrently = current.find((placement) => placement.id === movedItemId);
|
||||
if (!movedInitially || !movedCurrently) return null;
|
||||
if (
|
||||
movedInitially.x === movedCurrently.x &&
|
||||
movedInitially.y === movedCurrently.y &&
|
||||
movedInitially.w === movedCurrently.w &&
|
||||
movedInitially.h === movedCurrently.h
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displaced = initial.find(
|
||||
(placement) => placement.id !== movedItemId && placement.x === movedCurrently.x && placement.y === movedCurrently.y,
|
||||
);
|
||||
if (!displaced) return null;
|
||||
|
||||
const swapped = initial.map((placement) => {
|
||||
if (placement.id === movedItemId) {
|
||||
return {
|
||||
...placement,
|
||||
x: displaced.x,
|
||||
y: displaced.y,
|
||||
};
|
||||
}
|
||||
if (placement.id === displaced.id) {
|
||||
return {
|
||||
...placement,
|
||||
x: movedInitially.x,
|
||||
y: movedInitially.y,
|
||||
};
|
||||
}
|
||||
return placement;
|
||||
});
|
||||
|
||||
return placementsFit(swapped, bounds) ? swapped : null;
|
||||
};
|
||||
|
||||
const placementsFit = (
|
||||
placements: readonly SectionGridPlacement[],
|
||||
{ columnCount, rowCount }: { columnCount: number; rowCount?: number },
|
||||
) =>
|
||||
placements.every(
|
||||
(placement) =>
|
||||
placement.x >= 0 &&
|
||||
placement.y >= 0 &&
|
||||
placement.x + placement.w <= columnCount &&
|
||||
(rowCount === undefined || placement.y + placement.h <= rowCount),
|
||||
) &&
|
||||
placements.every((placement, index) =>
|
||||
placements.slice(index + 1).every((other) => !placementsOverlap(placement, other)),
|
||||
);
|
||||
|
||||
const placementsOverlap = (first: SectionGridPlacement, second: SectionGridPlacement) =>
|
||||
first.x < second.x + second.w &&
|
||||
first.x + first.w > second.x &&
|
||||
first.y < second.y + second.h &&
|
||||
first.y + first.h > second.y;
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export type GridEditorRuntimeStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
export interface GridResizePreview {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
const GridEditorRuntimeContext = createContext<GridEditorRuntimeStatus>("idle");
|
||||
const GridResizePreviewContext = createContext<GridResizePreview | null>(null);
|
||||
|
||||
export const GridEditorRuntimeProvider = ({
|
||||
children,
|
||||
status,
|
||||
}: PropsWithChildren<{ status: GridEditorRuntimeStatus }>) => (
|
||||
<GridEditorRuntimeContext.Provider value={status}>{children}</GridEditorRuntimeContext.Provider>
|
||||
);
|
||||
|
||||
export const useGridEditorRuntimeStatus = () => useContext(GridEditorRuntimeContext);
|
||||
|
||||
export const GridResizePreviewProvider = ({
|
||||
children,
|
||||
value,
|
||||
}: PropsWithChildren<{ value: GridResizePreview | null }>) => (
|
||||
<GridResizePreviewContext.Provider value={value}>{children}</GridResizePreviewContext.Provider>
|
||||
);
|
||||
|
||||
export const useGridResizePreview = (entryId: string) => {
|
||||
const preview = useContext(GridResizePreviewContext);
|
||||
return preview?.id === entryId ? preview : null;
|
||||
};
|
||||
@@ -1,130 +1,256 @@
|
||||
.grid-stack.grid-stack-editing > .grid-stack-placeholder > .placeholder-content {
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
|
||||
.board-grid-editor {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
overflow: visible;
|
||||
background-image:
|
||||
linear-gradient(to right, color-mix(in srgb, var(--mantine-color-dimmed) 18%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, color-mix(in srgb, var(--mantine-color-dimmed) 18%, transparent) 1px, transparent 1px);
|
||||
background-size: 224px 224px;
|
||||
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] .board-grid-entry,
|
||||
body[data-board-grid-interacting] .board-grid-placeholder {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.board-grid-content-mount,
|
||||
.board-grid-item-content,
|
||||
.board-grid-item-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);
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item.ui-draggable-dragging {
|
||||
filter: drop-shadow(var(--mantine-shadow-lg));
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .grid-stack-item-content {
|
||||
border-radius: var(--mantine-radius-md);
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: 2px;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
outline-color 160ms ease,
|
||||
background-color 160ms ease;
|
||||
left 100ms ease,
|
||||
top 100ms ease,
|
||||
width 100ms ease,
|
||||
height 100ms ease;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item:hover > .grid-stack-item-content,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item:focus-within > .grid-stack-item-content {
|
||||
outline-color: color-mix(in srgb, var(--mantine-primary-color-filled) 72%, transparent);
|
||||
.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;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item.ui-draggable-dragging > .grid-stack-item-content,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item.ui-resizable-resizing > .grid-stack-item-content {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 3px;
|
||||
.board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
|
||||
outline-color: var(--mantine-color-red-filled);
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-handle {
|
||||
.board-grid-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
display: block;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w {
|
||||
width: 28px;
|
||||
.board-grid-resize-handle[data-grid-resize-disabled="true"] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s {
|
||||
height: 28px;
|
||||
.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;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: var(--mantine-color-dimmed);
|
||||
background-image: none;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
transform: none;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw::after {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
content: "";
|
||||
border-bottom: 2px solid currentColor;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se::after {
|
||||
right: 8px;
|
||||
border-right: 2px solid currentColor;
|
||||
border-radius: 0 0 3px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw::after {
|
||||
left: 8px;
|
||||
border-left: 2px solid currentColor;
|
||||
border-radius: 0 0 0 3px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se:hover,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw:hover {
|
||||
color: var(--mantine-primary-color-filled);
|
||||
background-color: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
background-color: var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after {
|
||||
top: 25%;
|
||||
bottom: 25%;
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after {
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after {
|
||||
left: 5px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s::after {
|
||||
right: 25%;
|
||||
bottom: 5px;
|
||||
left: 25%;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e:hover::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w:hover::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s:hover::after {
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.grid-stack.grid-stack-editing,
|
||||
.grid-stack.grid-stack-editing .grid-stack-item,
|
||||
.grid-stack.grid-stack-editing .grid-stack-item-content,
|
||||
.grid-stack.grid-stack-editing .ui-resizable-handle::after {
|
||||
transition: none !important;
|
||||
.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: -12px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
bottom: -12px;
|
||||
}
|
||||
|
||||
.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: -12px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
left: -12px;
|
||||
}
|
||||
|
||||
.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: -12px;
|
||||
right: -12px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"] {
|
||||
right: -12px;
|
||||
bottom: -12px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
|
||||
bottom: -12px;
|
||||
left: -12px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
top: -12px;
|
||||
left: -12px;
|
||||
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-handle::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,40 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { GridItemHTMLElement } from "gridstack";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useIntegrations } from "@homarr/auth/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
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 { decorateGridResizeHandles } from "./grid-resize-handles";
|
||||
import { useGridResizePreview } from "./grid-editor-runtime";
|
||||
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;
|
||||
releaseContainer: (id: string, container: HTMLElement) => void;
|
||||
}
|
||||
|
||||
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* One portal registry owns every edit-mode item on the board. Its content
|
||||
* containers are stable even when GridStack replaces or transfers the outer
|
||||
* positioning shell, so moving between grids does not remount widget 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 currentLayoutId = useCurrentLayout();
|
||||
const integrations = useIntegrations();
|
||||
const containersRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const [containers, setContainers] = useState<Map<string, HTMLElement>>(() => new Map());
|
||||
@@ -48,36 +46,49 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
if (existing) return existing;
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "grid-stack-item-content";
|
||||
container.style.overflow = "visible";
|
||||
container.className = "board-grid-item-content";
|
||||
container.dataset.boardGridPortal = id;
|
||||
containersRef.current.set(id, container);
|
||||
setContainers(new Map(containersRef.current));
|
||||
return container;
|
||||
}, []);
|
||||
|
||||
const releaseContainer = useCallback((id: string, container: HTMLElement) => {
|
||||
// Controlled cross-grid moves remove the source shell before the target
|
||||
// adopts this container. Wait until all grid effects in the commit ran.
|
||||
queueMicrotask(() => {
|
||||
if (container.isConnected || containersRef.current.get(id) !== container) return;
|
||||
containersRef.current.delete(id);
|
||||
setContainers(new Map(containersRef.current));
|
||||
});
|
||||
}, []);
|
||||
// Keep acquired nodes for the host's lifetime. Cross-grid commits can
|
||||
// transiently render neither shell, and detaching the node must not remount
|
||||
// live widget state before the destination shell adopts it.
|
||||
|
||||
const value = useMemo<GridPortalHostContextValue>(
|
||||
() => ({
|
||||
announce,
|
||||
integrations,
|
||||
containers,
|
||||
acquireContainer,
|
||||
releaseContainer,
|
||||
}),
|
||||
[acquireContainer, announce, integrations, releaseContainer],
|
||||
[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
|
||||
@@ -85,7 +96,6 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
<GridPortalContent
|
||||
itemId={id}
|
||||
ownerSectionId={ownerSectionId}
|
||||
container={container}
|
||||
integrations={integrations}
|
||||
announce={announce}
|
||||
/>,
|
||||
@@ -94,10 +104,7 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
)
|
||||
: null;
|
||||
})}
|
||||
<div className={classes.liveRegion} aria-live="polite" aria-atomic="true">
|
||||
<span key={announcement.id}>{announcement.message}</span>
|
||||
</div>
|
||||
</GridPortalHostContext.Provider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -110,40 +117,33 @@ export const useBoardGridPortalHost = () => {
|
||||
interface GridPortalContentProps {
|
||||
itemId: string;
|
||||
ownerSectionId: string;
|
||||
container: HTMLElement;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
announce: (message: string) => void;
|
||||
}
|
||||
|
||||
const GridPortalContent = ({ itemId, ownerSectionId, container, integrations, announce }: GridPortalContentProps) => {
|
||||
const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: GridPortalContentProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { items, innerSections } = useSectionItems(ownerSectionId);
|
||||
const entry = [...items, ...innerSections].find((candidate) => candidate.id === itemId);
|
||||
const entryPreview = useGridResizePreview(itemId);
|
||||
const ownerPreview = useGridResizePreview(ownerSectionId);
|
||||
const persistedEntry = [...items, ...innerSections].find((candidate) => candidate.id === itemId);
|
||||
const entry = withResizePreview(persistedEntry, entryPreview);
|
||||
const rawSection = board.sections.find((section) => section.id === ownerSectionId);
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
const section = toGridSection(rawSection, currentLayoutId);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const itemElement = container.parentElement as GridItemHTMLElement | null;
|
||||
if (!itemElement) return;
|
||||
|
||||
itemElement.gridstackNode?.grid?.refreshDragHandles(itemElement);
|
||||
decorateGridResizeHandles(itemElement, {
|
||||
minW: itemElement.gridstackNode?.minW,
|
||||
minH: itemElement.gridstackNode?.minH,
|
||||
});
|
||||
}, [container, ownerSectionId]);
|
||||
const persistedSection = toGridSection(rawSection, currentLayoutId);
|
||||
const section =
|
||||
persistedSection?.kind === "container" ? withResizePreview(persistedSection, ownerPreview) : persistedSection;
|
||||
|
||||
if (!entry || !section || !currentLayout) return null;
|
||||
|
||||
const columnCount =
|
||||
section.kind === "dynamic"
|
||||
section.kind === "container"
|
||||
? section.width
|
||||
: section.kind === "empty"
|
||||
? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
|
||||
: currentLayout.columnCount;
|
||||
const configuredMaxRowCount = section.kind === "dynamic" ? section.height : null;
|
||||
const configuredMaxRowCount = section.kind === "container" ? section.height : null;
|
||||
const contentRowCount = Math.max(
|
||||
1,
|
||||
...items.map((item) => item.yOffset + item.height),
|
||||
@@ -168,12 +168,28 @@ const GridPortalContent = ({ itemId, ownerSectionId, container, integrations, an
|
||||
);
|
||||
};
|
||||
|
||||
const withResizePreview = <
|
||||
TValue extends { xOffset: number; yOffset: number; width: number; height: number } | null | undefined,
|
||||
>(
|
||||
value: TValue,
|
||||
preview: { x: number; y: number; w: number; h: number } | null,
|
||||
): TValue =>
|
||||
value && preview
|
||||
? ({
|
||||
...value,
|
||||
xOffset: preview.x,
|
||||
yOffset: preview.y,
|
||||
width: preview.w,
|
||||
height: preview.h,
|
||||
} as TValue)
|
||||
: value;
|
||||
|
||||
const toGridSection = (
|
||||
section: Section | undefined,
|
||||
layoutId: string,
|
||||
): Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem | null => {
|
||||
): Exclude<Section, { kind: "container" }> | ContainerSectionItem | null => {
|
||||
if (!section) return null;
|
||||
if (section.kind !== "dynamic") return section;
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) return null;
|
||||
@@ -191,7 +207,7 @@ const getOwnerSectionId = (board: ReturnType<typeof useRequiredBoard>, layoutId:
|
||||
return item.layouts.find((layout) => layout.layoutId === layoutId)?.sectionId ?? null;
|
||||
}
|
||||
|
||||
const section = board.sections.find((candidate) => candidate.kind === "dynamic" && candidate.id === entryId);
|
||||
if (!section || section.kind !== "dynamic") return 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;
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import { LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
|
||||
interface GridResizeMinimum {
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
}
|
||||
|
||||
export const decorateGridResizeHandles = (element: HTMLElement, minimum: GridResizeMinimum = {}) => {
|
||||
element.style.minWidth = `${(minimum.minW ?? 1) * LOGICAL_GRID_PITCH}px`;
|
||||
element.style.minHeight = `${(minimum.minH ?? 1) * LOGICAL_GRID_PITCH}px`;
|
||||
|
||||
for (const handle of element.querySelectorAll<HTMLElement>(".ui-resizable-handle")) {
|
||||
const direction = Array.from(handle.classList).find((className) =>
|
||||
/^ui-resizable-(?:n|ne|e|se|s|sw|w|nw)$/.test(className),
|
||||
);
|
||||
if (!direction) continue;
|
||||
const directionName = direction.replace("ui-resizable-", "");
|
||||
|
||||
handle.setAttribute(
|
||||
"data-testid",
|
||||
directionName === "se" ? "board-grid-resize-handle" : `board-grid-resize-handle-${directionName}`,
|
||||
);
|
||||
handle.setAttribute("data-grid-resize-direction", directionName);
|
||||
handle.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
};
|
||||
@@ -14,10 +14,6 @@
|
||||
inset: 0 auto auto 0;
|
||||
}
|
||||
|
||||
.editorGrid:not(:global(.grid-stack-editing)) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.staticItem {
|
||||
overflow: visible;
|
||||
transition:
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import {
|
||||
getCollapsedDisplayLayout,
|
||||
getEditableCanvasAttributes,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
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";
|
||||
@@ -63,7 +64,7 @@ function GridEditorLoading() {
|
||||
}
|
||||
|
||||
interface SectionGridProps {
|
||||
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
|
||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
||||
columnCount: number;
|
||||
requestedRowCount?: number;
|
||||
label: string;
|
||||
@@ -80,6 +81,7 @@ export const SectionGrid = ({
|
||||
className,
|
||||
}: SectionGridProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const editorRuntimeStatus = useGridEditorRuntimeStatus();
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { items, innerSections } = useSectionItems(section.id);
|
||||
@@ -90,7 +92,7 @@ export const SectionGrid = ({
|
||||
new Map(
|
||||
innerSections.map((innerSection) => [
|
||||
innerSection.id,
|
||||
getDynamicSectionMinimumSize(board, currentLayoutId, innerSection.id),
|
||||
getContainerMinimumSize(board, currentLayoutId, innerSection.id),
|
||||
]),
|
||||
),
|
||||
[board, currentLayoutId, innerSections],
|
||||
@@ -144,7 +146,7 @@ export const SectionGrid = ({
|
||||
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 fixedRowCount = section.kind === "dynamic";
|
||||
const fixedRowCount = section.kind === "container";
|
||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||
const logicalHeight = getLogicalTrackSize(rowCount);
|
||||
const canvasAttributes = isEditMode
|
||||
@@ -172,21 +174,24 @@ export const SectionGrid = ({
|
||||
data-section-kind={section.kind}
|
||||
data-rail-placement={railPlacement}
|
||||
>
|
||||
{isEditMode ? (
|
||||
{isEditMode && editorRuntimeStatus === "ready" ? (
|
||||
<GridEditor
|
||||
sectionId={section.id}
|
||||
columnCount={columnCount}
|
||||
rowCount={rowCount}
|
||||
fixedRowCount={fixedRowCount}
|
||||
placements={displayPlacements}
|
||||
className={combineClasses("grid-stack", classes.editorGrid)}
|
||||
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>
|
||||
@@ -196,19 +201,30 @@ export const SectionGrid = ({
|
||||
);
|
||||
};
|
||||
|
||||
const getDynamicSectionMinimumSize = (
|
||||
const getContainerMinimumSize = (
|
||||
board: ReturnType<typeof useRequiredBoard>,
|
||||
layoutId: string,
|
||||
sectionId: string,
|
||||
) => {
|
||||
const directItems = board.items.flatMap((item) => {
|
||||
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 = board.sections.flatMap((candidate) => {
|
||||
if (candidate.kind !== "dynamic") return [];
|
||||
const directSections: PlacementBounds[] = board.sections.flatMap((candidate) => {
|
||||
if (candidate.kind !== "container") return [];
|
||||
const layout = candidate.layouts.find((candidateLayout) => candidateLayout.layoutId === layoutId);
|
||||
return layout?.parentSectionId === sectionId ? [layout] : [];
|
||||
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];
|
||||
|
||||
@@ -218,6 +234,13 @@ const getDynamicSectionMinimumSize = (
|
||||
};
|
||||
};
|
||||
|
||||
interface PlacementBounds {
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const withPlacement = <
|
||||
TItem extends {
|
||||
id: string;
|
||||
|
||||
@@ -59,6 +59,10 @@ vi.mock("../use-grid-layout-actions", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../grid-editor-runtime", () => ({
|
||||
useGridEditorRuntimeStatus: () => "ready",
|
||||
}));
|
||||
|
||||
vi.mock("../../section-context", () => ({
|
||||
useSectionContext: () => ({
|
||||
section: { id: "root", kind: "empty", options: {} },
|
||||
@@ -98,7 +102,7 @@ describe("fixed grid item behavior", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test("moves with the keyboard and deterministically pushes a collision down", () => {
|
||||
test("moves with the keyboard and swaps an equal-sized destination", () => {
|
||||
renderWeather(root);
|
||||
|
||||
const entry = getEditorEntry(container);
|
||||
@@ -110,7 +114,7 @@ describe("fixed grid item behavior", () => {
|
||||
sectionId: "root",
|
||||
placements: [
|
||||
{ id: "weather", type: "item", x: 1, y: 0, w: 1, h: 1 },
|
||||
{ id: "clock", type: "item", x: 1, y: 1, w: 1, h: 1 },
|
||||
{ id: "clock", type: "item", x: 0, y: 0, w: 1, h: 1 },
|
||||
],
|
||||
});
|
||||
expect(mocks.announce).toHaveBeenCalledTimes(2);
|
||||
@@ -140,6 +144,25 @@ describe("fixed grid item behavior", () => {
|
||||
],
|
||||
});
|
||||
|
||||
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(
|
||||
@@ -156,6 +179,27 @@ describe("fixed grid item behavior", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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("uses the whole tile as the focusable move surface with a visual drag affordance", () => {
|
||||
getWeatherMock().advancedOptions.title = " Balcony forecast ";
|
||||
renderWeather(root);
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getDropSwapPlacements } from "../grid-drop-swap";
|
||||
import type { SectionGridPlacement } from "../use-grid-layout-actions";
|
||||
|
||||
const first: SectionGridPlacement = { id: "first", type: "item", x: 0, y: 0, w: 1, h: 1 };
|
||||
const second: SectionGridPlacement = { id: "second", type: "item", x: 1, y: 0, w: 1, h: 1 };
|
||||
const third: SectionGridPlacement = { id: "third", type: "item", x: 2, y: 0, w: 2, h: 1 };
|
||||
const initial = [first, second, third];
|
||||
const bounds = { columnCount: 4 };
|
||||
|
||||
describe("grid drop swapping", () => {
|
||||
test("swaps equally-sized items when one takes the other's exact slot", () => {
|
||||
const current: SectionGridPlacement[] = [{ ...first, x: 1 }, { ...second, y: 1 }, third];
|
||||
|
||||
expect(getDropSwapPlacements(initial, current, "first", bounds)).toEqual([
|
||||
{ ...first, x: 1 },
|
||||
{ ...second, x: 0 },
|
||||
third,
|
||||
]);
|
||||
});
|
||||
|
||||
test("swaps different-sized items when both destinations remain valid", () => {
|
||||
const wide: SectionGridPlacement = { id: "wide", type: "item", x: 0, y: 0, w: 2, h: 1 };
|
||||
const compact: SectionGridPlacement = { id: "compact", type: "item", x: 2, y: 0, w: 1, h: 1 };
|
||||
const current = [
|
||||
{ ...wide, x: 2 },
|
||||
{ ...compact, y: 1 },
|
||||
];
|
||||
|
||||
expect(getDropSwapPlacements([wide, compact], current, "wide", bounds)).toEqual([
|
||||
{ ...wide, x: 2 },
|
||||
{ ...compact, x: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps push behavior when a swap would overlap another item or exceed the section", () => {
|
||||
const differentSize: SectionGridPlacement[] = [{ ...first, x: 2 }, second, { ...third, y: 1 }];
|
||||
const partialPlacement: SectionGridPlacement[] = [{ ...third, x: 1 }, first, { ...second, y: 1 }];
|
||||
|
||||
expect(getDropSwapPlacements(initial, differentSize, "first", bounds)).toBeNull();
|
||||
expect(getDropSwapPlacements(initial, partialPlacement, "third", bounds)).toBeNull();
|
||||
expect(getDropSwapPlacements(initial, [{ ...third, x: 1 }, first, second], "third", { columnCount: 2 })).toBeNull();
|
||||
});
|
||||
|
||||
test("does nothing when the item did not move", () => {
|
||||
expect(getDropSwapPlacements(initial, initial, "first", bounds)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,10 +9,10 @@ const sectionGridPath = resolve(gridDirectory, "section-grid.tsx");
|
||||
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
|
||||
|
||||
describe("board grid import boundary", () => {
|
||||
test("keeps every GridStack runtime import inside the edit-only module", () => {
|
||||
test("keeps every current dnd-kit runtime import inside the edit-only module", () => {
|
||||
const runtimeImports = getSourceFiles(nextjsSourceDirectory)
|
||||
.flatMap(getRuntimeImports)
|
||||
.filter(({ specifier }) => specifier === "gridstack" || specifier.startsWith("gridstack/"))
|
||||
.filter(({ specifier }) => CURRENT_DND_KIT_PACKAGES.has(specifier))
|
||||
.map(({ filePath, kind, specifier }) => ({
|
||||
file: toSourceRelativePath(filePath),
|
||||
kind,
|
||||
@@ -28,12 +28,22 @@ describe("board grid import boundary", () => {
|
||||
{
|
||||
file: "components/board/sections/grid/grid-editor.tsx",
|
||||
kind: "static",
|
||||
specifier: "gridstack",
|
||||
specifier: "@dnd-kit/abstract",
|
||||
},
|
||||
{
|
||||
file: "components/board/sections/grid/grid-editor.tsx",
|
||||
kind: "static",
|
||||
specifier: "gridstack/dist/gridstack.min.css",
|
||||
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/react",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -88,15 +98,14 @@ describe("board grid import boundary", () => {
|
||||
}
|
||||
|
||||
const editConditional = findAncestor(gridEditorUsage, ts.isConditionalExpression);
|
||||
expect(
|
||||
editConditional !== undefined &&
|
||||
ts.isIdentifier(editConditional.condition) &&
|
||||
editConditional.condition.text === "isEditMode" &&
|
||||
isDescendantOf(gridEditorUsage, editConditional.whenTrue),
|
||||
).toBe(true);
|
||||
expect(editConditional && isDescendantOf(gridEditorUsage, editConditional.whenTrue)).toBe(true);
|
||||
expect(editConditional?.condition.getText(sourceFile)).toContain("isEditMode");
|
||||
expect(editConditional?.condition.getText(sourceFile)).toContain('editorRuntimeStatus === "ready"');
|
||||
});
|
||||
});
|
||||
|
||||
const CURRENT_DND_KIT_PACKAGES = new Set(["@dnd-kit/abstract", "@dnd-kit/collision", "@dnd-kit/dom", "@dnd-kit/react"]);
|
||||
|
||||
interface RuntimeImport {
|
||||
filePath: string;
|
||||
kind: "dynamic" | "require" | "static";
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { LOGICAL_GRID_PITCH } from "../../../layout";
|
||||
import { decorateGridResizeHandles } from "../grid-resize-handles";
|
||||
|
||||
describe("grid resize handle decoration", () => {
|
||||
test("restores accessible hit-target markers after GridStack refreshes its handles", () => {
|
||||
const item = document.createElement("div");
|
||||
item.innerHTML = `
|
||||
<div class="ui-resizable-handle ui-resizable-e"></div>
|
||||
<div class="ui-resizable-handle ui-resizable-se"></div>
|
||||
`;
|
||||
|
||||
decorateGridResizeHandles(item, { minW: 2, minH: 3 });
|
||||
|
||||
const east = item.querySelector<HTMLElement>(".ui-resizable-e");
|
||||
const southeast = item.querySelector<HTMLElement>(".ui-resizable-se");
|
||||
expect(east?.dataset.testid).toBe("board-grid-resize-handle-e");
|
||||
expect(east?.dataset.gridResizeDirection).toBe("e");
|
||||
expect(east?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(southeast?.dataset.testid).toBe("board-grid-resize-handle");
|
||||
expect(southeast?.dataset.gridResizeDirection).toBe("se");
|
||||
expect(southeast?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(item.style.minWidth).toBe(`${2 * LOGICAL_GRID_PITCH}px`);
|
||||
expect(item.style.minHeight).toBe(`${3 * LOGICAL_GRID_PITCH}px`);
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,9 @@ export interface CommitSectionGridInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits a complete section snapshot in one cache update. GridStack can move
|
||||
* several neighbours for one gesture, so persisting only the active item would
|
||||
* lose its collision result.
|
||||
* 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();
|
||||
@@ -34,48 +34,68 @@ export const useGridLayoutActions = () => {
|
||||
),
|
||||
);
|
||||
updateBoard((previous) => {
|
||||
return {
|
||||
...previous,
|
||||
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;
|
||||
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,
|
||||
};
|
||||
});
|
||||
return {
|
||||
...layout,
|
||||
sectionId,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
};
|
||||
});
|
||||
|
||||
return layouts.some((layout, index) => layout !== item.layouts[index]) ? { ...item, layouts } : item;
|
||||
}),
|
||||
sections: previous.sections.map((innerSection) => {
|
||||
if (innerSection.kind !== "dynamic") 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 (!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,
|
||||
};
|
||||
});
|
||||
return {
|
||||
...layout,
|
||||
parentSectionId: sectionId,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
};
|
||||
});
|
||||
|
||||
return layouts.some((layout, index) => layout !== innerSection.layouts[index])
|
||||
? { ...innerSection, layouts }
|
||||
: innerSection;
|
||||
}),
|
||||
};
|
||||
if (!layouts.some((layout, index) => layout !== innerSection.layouts[index])) return innerSection;
|
||||
hasChanges = true;
|
||||
return { ...innerSection, layouts };
|
||||
});
|
||||
|
||||
return hasChanges ? { ...previous, items, sections } : previous;
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
|
||||
@@ -29,7 +29,7 @@ export const useSectionActions = () => {
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return same section if section is not the one we're moving
|
||||
if (section.id !== innerSectionId) return section;
|
||||
if (section.kind !== "dynamic") return section;
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
@@ -57,7 +57,7 @@ export const useSectionActions = () => {
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return section without changes when not the section we're moving
|
||||
if (section.id !== innerSectionId) return section;
|
||||
if (section.kind !== "dynamic") return section;
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ import { createContext, useContext } from "react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
|
||||
import type { DynamicSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { ContainerSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
interface SectionContextProps {
|
||||
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
|
||||
innerSections: DynamicSectionItem[];
|
||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
||||
innerSections: ContainerSectionItem[];
|
||||
items: SectionItem[];
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
columnCount: number;
|
||||
|
||||
@@ -24,8 +24,8 @@ describe("section collapse storage", () => {
|
||||
test("uses the corrected fallback when no browser preference exists", () => {
|
||||
const storage = createStorage();
|
||||
|
||||
expect(readSectionCollapsedFromStorage(storage, "category", true)).toBe(true);
|
||||
expect(readSectionCollapsedFromStorage(storage, "dynamic", false)).toBe(false);
|
||||
expect(readSectionCollapsedFromStorage(storage, "first", true)).toBe(true);
|
||||
expect(readSectionCollapsedFromStorage(storage, "second", false)).toBe(false);
|
||||
});
|
||||
|
||||
test("writes only the versioned corrected state", () => {
|
||||
|
||||
@@ -3,40 +3,40 @@ import { describe, expect, test } from "vitest";
|
||||
import { getSectionItemsForLayout } from "../use-open-section-apps";
|
||||
|
||||
describe("getSectionItemsForLayout", () => {
|
||||
test("includes items in recursively nested dynamic sections", () => {
|
||||
test("includes items in recursively nested containers", () => {
|
||||
const board = {
|
||||
sections: [
|
||||
{ id: "category", kind: "category" },
|
||||
{ id: "root", kind: "empty" },
|
||||
{
|
||||
id: "first",
|
||||
kind: "dynamic",
|
||||
layouts: [{ layoutId: "desktop", parentSectionId: "category" }],
|
||||
kind: "container",
|
||||
layouts: [{ layoutId: "desktop", parentSectionId: "root" }],
|
||||
},
|
||||
{
|
||||
id: "second",
|
||||
kind: "dynamic",
|
||||
kind: "container",
|
||||
layouts: [{ layoutId: "desktop", parentSectionId: "first" }],
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{ id: "direct", layouts: [{ layoutId: "desktop", sectionId: "category" }] },
|
||||
{ id: "direct", layouts: [{ layoutId: "desktop", sectionId: "root" }] },
|
||||
{ id: "nested", layouts: [{ layoutId: "desktop", sectionId: "second" }] },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getSectionItemsForLayout(board, "category", "desktop").map((item) => item.id)).toEqual(["direct", "nested"]);
|
||||
expect(getSectionItemsForLayout(board, "root", "desktop").map((item) => item.id)).toEqual(["direct", "nested"]);
|
||||
});
|
||||
|
||||
test("uses containment from only the requested responsive layout", () => {
|
||||
const board = {
|
||||
sections: [
|
||||
{ id: "category", kind: "category" },
|
||||
{ id: "other", kind: "category" },
|
||||
{ id: "root", kind: "empty" },
|
||||
{ id: "other", kind: "empty" },
|
||||
{
|
||||
id: "dynamic",
|
||||
kind: "dynamic",
|
||||
id: "container",
|
||||
kind: "container",
|
||||
layouts: [
|
||||
{ layoutId: "mobile", parentSectionId: "category" },
|
||||
{ layoutId: "mobile", parentSectionId: "root" },
|
||||
{ layoutId: "desktop", parentSectionId: "other" },
|
||||
],
|
||||
},
|
||||
@@ -45,15 +45,15 @@ describe("getSectionItemsForLayout", () => {
|
||||
{
|
||||
id: "app",
|
||||
layouts: [
|
||||
{ layoutId: "mobile", sectionId: "dynamic" },
|
||||
{ layoutId: "desktop", sectionId: "dynamic" },
|
||||
{ layoutId: "mobile", sectionId: "container" },
|
||||
{ layoutId: "desktop", sectionId: "container" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getSectionItemsForLayout(board, "category", "mobile").map((item) => item.id)).toEqual(["app"]);
|
||||
expect(getSectionItemsForLayout(board, "category", "desktop")).toEqual([]);
|
||||
expect(getSectionItemsForLayout(board, "root", "mobile").map((item) => item.id)).toEqual(["app"]);
|
||||
expect(getSectionItemsForLayout(board, "root", "desktop")).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not loop when malformed section containment is cyclic", () => {
|
||||
@@ -61,12 +61,12 @@ describe("getSectionItemsForLayout", () => {
|
||||
sections: [
|
||||
{
|
||||
id: "root",
|
||||
kind: "dynamic",
|
||||
kind: "container",
|
||||
layouts: [{ layoutId: "desktop", parentSectionId: "child" }],
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
kind: "dynamic",
|
||||
kind: "container",
|
||||
layouts: [{ layoutId: "desktop", parentSectionId: "root" }],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { filterByItemKind } from "./category/filter";
|
||||
import { filterByItemKind } from "./filter-by-item-kind";
|
||||
|
||||
interface SectionItemLayout {
|
||||
layoutId: string;
|
||||
@@ -35,8 +35,8 @@ interface SectionTree<TItem extends SectionTreeItem> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds every item contained by a section in one responsive layout. Dynamic
|
||||
* sections can be nested, so their descendants are traversed recursively.
|
||||
* Finds every item contained by a section in one responsive layout. Containers
|
||||
* can be nested, so their descendants are traversed recursively.
|
||||
*/
|
||||
export const getSectionItemsForLayout = <TItem extends SectionTreeItem>(
|
||||
board: SectionTree<TItem>,
|
||||
@@ -46,7 +46,7 @@ export const getSectionItemsForLayout = <TItem extends SectionTreeItem>(
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
|
||||
for (const section of board.sections) {
|
||||
if (section.kind !== "dynamic") continue;
|
||||
if (section.kind !== "container") continue;
|
||||
|
||||
const layout = section.layouts?.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) continue;
|
||||
|
||||
@@ -2,16 +2,16 @@ import { useMemo } from "react";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const useSectionItems = (sectionId: string): { innerSections: DynamicSectionItem[]; items: SectionItem[] } => {
|
||||
export const useSectionItems = (sectionId: string): { innerSections: ContainerSectionItem[]; items: SectionItem[] } => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
|
||||
const innerSections = useMemo(
|
||||
() =>
|
||||
board.sections
|
||||
.filter((innerSection) => innerSection.kind === "dynamic")
|
||||
.filter((innerSection) => innerSection.kind === "container")
|
||||
.map(({ layouts, ...innerSection }) => {
|
||||
const layout = layouts.find((layout) => layout.layoutId === currentLayoutId);
|
||||
|
||||
|
||||
Reference in new issue
Block a user