fix: address board grid review
This commit is contained in:
54 files changed
+657
-323
No files matched your search
@@ -9,8 +9,9 @@ import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@ho
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount, getLayoutRowCount } from "../layout";
|
||||
import { getLayoutRowCount } from "../layout";
|
||||
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
|
||||
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
|
||||
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
|
||||
|
||||
@@ -117,7 +118,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
const targetPlacements = getSectionPlacements(board, currentLayoutId, target.id).filter(
|
||||
const targetPlacements = getSectionGridPlacements(board, currentLayoutId, target.id).filter(
|
||||
(placement) => placement.id !== entry.id,
|
||||
);
|
||||
const resolvedTarget = resolvePinnedGridCollisions(
|
||||
@@ -137,7 +138,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
{
|
||||
layoutId: currentLayoutId,
|
||||
sectionId: sourceSectionId,
|
||||
placements: getSectionPlacements(board, currentLayoutId, sourceSectionId).filter(
|
||||
placements: getSectionGridPlacements(board, currentLayoutId, sourceSectionId).filter(
|
||||
(placement) => placement.id !== entry.id,
|
||||
),
|
||||
},
|
||||
@@ -284,52 +285,15 @@ export const getMoveTargets = (
|
||||
}));
|
||||
};
|
||||
|
||||
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 getSectionColumnCount = (board: Board, layoutId: string, sectionId: string) => {
|
||||
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
|
||||
if (!layout) throw new Error(`Layout "${layoutId}" was not found`);
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) throw new Error(`Section "${sectionId}" was not found`);
|
||||
|
||||
if (section.kind === "container") {
|
||||
const sectionLayout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!sectionLayout) throw new Error(`Section "${sectionId}" has no current layout`);
|
||||
return sectionLayout.width;
|
||||
}
|
||||
return getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset));
|
||||
const columnCount = getSectionGridColumnCount(board, layoutId, sectionId);
|
||||
if (columnCount === null) throw new Error(`Section "${sectionId}" has no current layout`);
|
||||
return columnCount;
|
||||
};
|
||||
|
||||
const getSectionMaxRowCount = (board: Board, layoutId: string, sectionId: string) => {
|
||||
@@ -343,7 +307,7 @@ const getSectionMaxRowCount = (board: Board, layoutId: string, sectionId: string
|
||||
|
||||
const getEntryMinimumSize = (board: Board, layoutId: string, entry: MovableEntry) => {
|
||||
if (entry.type === "item") return { width: 1, height: 1 };
|
||||
const children = getSectionPlacements(board, layoutId, entry.id);
|
||||
const children = getSectionGridPlacements(board, layoutId, entry.id);
|
||||
return {
|
||||
width: Math.max(1, ...children.map((child) => child.x + child.w)),
|
||||
height: Math.max(1, ...children.map((child) => child.y + child.h)),
|
||||
|
||||
+72
-4
@@ -58,7 +58,15 @@ describe("container actions", () => {
|
||||
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 board = new BoardMockBuilder()
|
||||
.addEmptySection({ id: "root" })
|
||||
.addSection(removed)
|
||||
.addSection(child)
|
||||
.addItem(nestedItem)
|
||||
.build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
const flattenedChild = result.sections.find(
|
||||
@@ -78,7 +86,7 @@ describe("container actions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes and resolves collisions while flattening malformed child geometry", () => {
|
||||
test("normalizes, pins the top-left child, and resolves collisions while flattening malformed geometry", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 2 })
|
||||
.build();
|
||||
@@ -111,7 +119,67 @@ describe("container actions", () => {
|
||||
);
|
||||
|
||||
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 });
|
||||
expect(layouts.a).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 1, width: 2, height: 1 });
|
||||
expect(layouts.b).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 1 });
|
||||
});
|
||||
|
||||
test("keeps the container when a child container has layout geometry that cannot be flattened", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "other-layout", parentSectionId: "root", xOffset: 3, yOffset: 4 })
|
||||
.build();
|
||||
const child = new ContainerSectionMockBuilder({ id: "child" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addSection(child).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
|
||||
expect(result).toBe(board);
|
||||
expect(result.sections).toContainEqual(removed);
|
||||
});
|
||||
|
||||
test("keeps the container when a child item has layout geometry that cannot be flattened", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "other-layout", parentSectionId: "root", xOffset: 3, yOffset: 4 })
|
||||
.build();
|
||||
const item = new ItemMockBuilder({ id: "item" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addItem(item).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
|
||||
expect(result).toBe(board);
|
||||
expect(result.sections).toContainEqual(removed);
|
||||
});
|
||||
|
||||
test("keeps the container when flattening would move a child back into the removed container", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "removed", xOffset: 3, yOffset: 4 })
|
||||
.build();
|
||||
const item = new ItemMockBuilder({ id: "item" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addItem(item).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
|
||||
expect(result).toBe(board);
|
||||
expect(result.sections).toContainEqual(removed);
|
||||
});
|
||||
|
||||
test("keeps the container when its destination is a child container", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "child", xOffset: 3, yOffset: 4 })
|
||||
.build();
|
||||
const child = new ContainerSectionMockBuilder({ id: "child" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addSection(child).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
|
||||
expect(result).toBe(board);
|
||||
expect(result.sections).toContainEqual(removed);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board, ContainerSection } from "~/app/[locale]/boards/_types";
|
||||
import { resolvePinnedGridCollisions } from "~/components/board/sections/grid/dnd";
|
||||
import {
|
||||
getSectionGridColumnCount,
|
||||
getSectionGridPlacements,
|
||||
} from "~/components/board/sections/grid/section-grid-placements";
|
||||
import type { SectionGridPlacement } from "~/components/board/sections/grid/use-grid-layout-actions";
|
||||
|
||||
export interface RemoveContainerInput {
|
||||
@@ -16,6 +18,27 @@ export const removeContainerCallback =
|
||||
);
|
||||
if (!sectionToRemove) return board;
|
||||
|
||||
const removedLayoutsById = new Map(sectionToRemove.layouts.map((layout) => [layout.layoutId, layout]));
|
||||
const childLayoutIds = [
|
||||
...board.sections.flatMap((section) =>
|
||||
section.kind === "container"
|
||||
? section.layouts.flatMap((layout) =>
|
||||
layout.parentSectionId === sectionToRemove.id ? [layout.layoutId] : [],
|
||||
)
|
||||
: [],
|
||||
),
|
||||
...board.items.flatMap((item) =>
|
||||
item.layouts.flatMap((layout) => (layout.sectionId === sectionToRemove.id ? [layout.layoutId] : [])),
|
||||
),
|
||||
];
|
||||
const hasUnresolvableChildLayout = childLayoutIds.some((layoutId) => {
|
||||
const removedLayout = removedLayoutsById.get(layoutId);
|
||||
return (
|
||||
!removedLayout || !isValidFlattenDestination(board, sectionToRemove.id, layoutId, removedLayout.parentSectionId)
|
||||
);
|
||||
});
|
||||
if (hasUnresolvableChildLayout) return board;
|
||||
|
||||
const flattened = {
|
||||
...board,
|
||||
sections: board.sections
|
||||
@@ -31,7 +54,7 @@ export const removeContainerCallback =
|
||||
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||
);
|
||||
if (!removedSectionLayout) throw new Error("Layout not found");
|
||||
if (!removedSectionLayout) return layout;
|
||||
|
||||
return {
|
||||
...layout,
|
||||
@@ -50,7 +73,7 @@ export const removeContainerCallback =
|
||||
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||
);
|
||||
if (!removedSectionLayout) throw new Error("Layout not found");
|
||||
if (!removedSectionLayout) return layout;
|
||||
|
||||
return {
|
||||
...layout,
|
||||
@@ -65,6 +88,29 @@ export const removeContainerCallback =
|
||||
return resolveFlattenedLayouts(flattened, sectionToRemove, board);
|
||||
};
|
||||
|
||||
const isValidFlattenDestination = (
|
||||
board: Board,
|
||||
removedSectionId: string,
|
||||
layoutId: string,
|
||||
destinationSectionId: string,
|
||||
) => {
|
||||
const visited = new Set<string>();
|
||||
let sectionId = destinationSectionId;
|
||||
|
||||
while (true) {
|
||||
if (sectionId === removedSectionId || visited.has(sectionId)) return false;
|
||||
visited.add(sectionId);
|
||||
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) return false;
|
||||
if (section.kind !== "container") return true;
|
||||
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) return false;
|
||||
sectionId = layout.parentSectionId;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveFlattenedLayouts = (board: Board, removed: ContainerSection, original: Board): Board => {
|
||||
const resolvedByLayoutAndId = new Map<string, SectionGridPlacement>();
|
||||
|
||||
@@ -80,13 +126,13 @@ const resolveFlattenedLayouts = (board: Board, removed: ContainerSection, origin
|
||||
return layout?.parentSectionId === removed.id ? [section.id] : [];
|
||||
}),
|
||||
];
|
||||
const pinnedId = movedIds.toSorted()[0];
|
||||
if (!pinnedId) continue;
|
||||
|
||||
const columnCount = getParentColumnCount(board, removedLayout.layoutId, removedLayout.parentSectionId);
|
||||
const columnCount = getSectionGridColumnCount(board, removedLayout.layoutId, removedLayout.parentSectionId);
|
||||
if (!columnCount) continue;
|
||||
const placements = getSectionPlacements(board, removedLayout.layoutId, removedLayout.parentSectionId);
|
||||
const pinned = placements.find((placement) => placement.id === pinnedId);
|
||||
const placements = getSectionGridPlacements(board, removedLayout.layoutId, removedLayout.parentSectionId);
|
||||
const movedIdSet = new Set(movedIds);
|
||||
const pinned = placements
|
||||
.filter((placement) => movedIdSet.has(placement.id))
|
||||
.toSorted((first, second) => first.y - second.y || first.x - second.x || first.id.localeCompare(second.id))[0];
|
||||
if (!pinned) continue;
|
||||
|
||||
for (const placement of resolvePinnedGridCollisions(placements, pinned, columnCount)) {
|
||||
@@ -133,47 +179,3 @@ const resolveFlattenedLayouts = (board: Board, removed: ContainerSection, origin
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
import type { ContainerSectionOptions } from "@homarr/validation/shared";
|
||||
|
||||
import { addContainerCallback } from "./actions/add-container";
|
||||
import type { RemoveContainerInput } from "./actions/remove-container";
|
||||
@@ -10,7 +9,7 @@ import { removeContainerCallback } from "./actions/remove-container";
|
||||
|
||||
interface UpdateContainerOptions {
|
||||
containerId: string;
|
||||
newOptions: z.infer<typeof containerSectionOptionsSchema>;
|
||||
newOptions: ContainerSectionOptions;
|
||||
}
|
||||
|
||||
export const useContainerActions = () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd";
|
||||
import type { GridTransaction } from "./dnd";
|
||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "./use-grid-layout-actions";
|
||||
@@ -109,19 +110,29 @@ export const FixedGridItem = ({
|
||||
const current = placements.find((candidate) => candidate.id === item.id);
|
||||
if (!current) return;
|
||||
|
||||
const transaction = beginGridTransaction(
|
||||
{
|
||||
grids: [
|
||||
{
|
||||
id: section.id,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
placements,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ activeId: current.id, sourceGridId: section.id },
|
||||
);
|
||||
let transaction: GridTransaction<SectionGridPlacement>;
|
||||
try {
|
||||
transaction = beginGridTransaction(
|
||||
{
|
||||
grids: [
|
||||
{
|
||||
id: section.id,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
placements,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ activeId: current.id, sourceGridId: section.id },
|
||||
);
|
||||
} catch {
|
||||
keyboardGridRef.current = {
|
||||
source: renderedPlacements,
|
||||
current: renderedPlacements,
|
||||
};
|
||||
setKeyboardEditing(false);
|
||||
return;
|
||||
}
|
||||
const delta = getKeyboardDelta(event.key);
|
||||
const result = event.shiftKey
|
||||
? previewGridResize(transaction, {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export const getGridDepth = (gridId: string, parentGridById: ReadonlyMap<string, string | null>) => {
|
||||
let depth = 0;
|
||||
let currentGridId = gridId;
|
||||
const visited = new Set([gridId]);
|
||||
|
||||
while (true) {
|
||||
const parentGridId = parentGridById.get(currentGridId);
|
||||
if (!parentGridId || visited.has(parentGridId)) return depth;
|
||||
|
||||
depth += 1;
|
||||
visited.add(parentGridId);
|
||||
currentGridId = parentGridId;
|
||||
}
|
||||
};
|
||||
@@ -5,7 +5,7 @@
|
||||
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;
|
||||
background-size: var(--board-grid-pitch) var(--board-grid-pitch);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,15 @@ import type {
|
||||
} from "@dnd-kit/react";
|
||||
import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDroppable } from "@dnd-kit/react";
|
||||
|
||||
import { useCurrentLayout } from "@homarr/boards/context";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize } from "~/components/board/layout";
|
||||
import {
|
||||
getLayoutRowCount,
|
||||
getLogicalItemStyle,
|
||||
getLogicalTrackSize,
|
||||
LOGICAL_GRID_PITCH,
|
||||
} from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import {
|
||||
@@ -38,6 +43,7 @@ import {
|
||||
previewGridResize,
|
||||
} from "./dnd";
|
||||
import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, TransactionalGridState } from "./dnd";
|
||||
import { getGridDepth } from "./grid-depth";
|
||||
import { GridResizePreviewProvider } from "./grid-editor-runtime";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
@@ -119,6 +125,7 @@ interface ResizePreviewInput {
|
||||
interface BoardGridEditorContextValue {
|
||||
interaction: GridInteraction | null;
|
||||
registerGrid: (grid: RegisteredGrid) => () => void;
|
||||
getDepth: (gridId: string) => number;
|
||||
isDropTargetEligible: (source: AbstractDraggable, targetGridId: string) => boolean;
|
||||
canAcceptDrop: (source: AbstractDraggable, targetGridId: string) => boolean;
|
||||
beginResize: (input: ResizeStartInput) => boolean;
|
||||
@@ -137,6 +144,7 @@ const useBoardGridEditor = () => {
|
||||
|
||||
/** One provider coordinates every root and nested grid on the board. */
|
||||
export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const t = useI18n();
|
||||
const { announce } = useBoardGridPortalHost();
|
||||
@@ -147,6 +155,19 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
const collisionFrameRef = useRef(0);
|
||||
const [interaction, setInteraction] = useState<GridInteraction | null>(null);
|
||||
const [overlayZoom, setOverlayZoom] = useState(1);
|
||||
const parentGridById = useMemo(
|
||||
() =>
|
||||
new Map<string, string | null>(
|
||||
board.sections.map((section): [string, string | null] => [
|
||||
section.id,
|
||||
section.kind === "container"
|
||||
? (section.layouts.find((layout) => layout.layoutId === currentLayoutId)?.parentSectionId ?? null)
|
||||
: null,
|
||||
]),
|
||||
),
|
||||
[board.sections, currentLayoutId],
|
||||
);
|
||||
const getDepth = useCallback((gridId: string) => getGridDepth(gridId, parentGridById), [parentGridById]);
|
||||
|
||||
const clearInteraction = useCallback(() => {
|
||||
window.cancelAnimationFrame(collisionFrameRef.current);
|
||||
@@ -512,11 +533,8 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
arePointerCoordinatesEqual(active.lastProcessedPointer, pointer);
|
||||
|
||||
if (canCommitRenderedPreview) {
|
||||
const activeAfterHitTest = activeRef.current;
|
||||
if (activeAfterHitTest?.valid && activeAfterHitTest.targetGridId) {
|
||||
commitActiveTransaction(renderedInteraction.state);
|
||||
return;
|
||||
}
|
||||
commitActiveTransaction(renderedInteraction.state);
|
||||
return;
|
||||
}
|
||||
|
||||
updateDragPreview(
|
||||
@@ -596,6 +614,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
() => ({
|
||||
interaction,
|
||||
registerGrid,
|
||||
getDepth,
|
||||
isDropTargetEligible,
|
||||
canAcceptDrop,
|
||||
beginResize,
|
||||
@@ -611,6 +630,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
interaction,
|
||||
isDropTargetEligible,
|
||||
previewResize,
|
||||
getDepth,
|
||||
registerGrid,
|
||||
],
|
||||
);
|
||||
@@ -671,12 +691,13 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
const GridCollisionSynchronizer = ({ interaction }: { interaction: GridInteraction | null }) => {
|
||||
const manager = useDragDropManager();
|
||||
const geometryKey = interaction ? getGridGeometryKey(interaction.state) : null;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!interaction || !manager) return;
|
||||
if (!geometryKey || !manager) return;
|
||||
for (const target of manager.registry.droppables.value) target.refreshShape();
|
||||
manager.collisionObserver.forceUpdate(true);
|
||||
}, [interaction, manager]);
|
||||
}, [geometryKey, manager]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -692,11 +713,11 @@ export default function GridEditor({
|
||||
}: GridEditorProps) {
|
||||
const t = useI18n();
|
||||
const { section, items, innerSections } = useSectionContext();
|
||||
const { interaction, registerGrid, isDropTargetEligible } = useBoardGridEditor();
|
||||
const { interaction, registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditor();
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const parentGridId = "parentSectionId" in section ? section.parentSectionId : null;
|
||||
const ownerPlacementId = "parentSectionId" in section ? section.id : null;
|
||||
const depth = getGridDepth(sectionId, parentGridId);
|
||||
const depth = getDepth(sectionId);
|
||||
const previewGrid = interaction?.state.grids.find((grid) => grid.id === sectionId);
|
||||
const controlledById = useMemo(() => new Map(placements.map((placement) => [placement.id, placement])), [placements]);
|
||||
const renderPlacements = useMemo(() => {
|
||||
@@ -792,7 +813,13 @@ export default function GridEditor({
|
||||
data-dnd-drop-target={isDropTarget ? "true" : "false"}
|
||||
data-dnd-drop-valid={isDropTarget ? String(targetPlacement !== null) : undefined}
|
||||
data-dnd-preview-revision={interaction?.targetGridId === sectionId ? interaction.previewRevision : undefined}
|
||||
style={{ width: getLogicalTrackSize(columnCount), height: getLogicalTrackSize(renderedRowCount) }}
|
||||
style={
|
||||
{
|
||||
width: getLogicalTrackSize(columnCount),
|
||||
height: getLogicalTrackSize(renderedRowCount),
|
||||
"--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{renderPlacements.map((placement) => {
|
||||
const entry = entryById.get(placement.id);
|
||||
@@ -1123,7 +1150,7 @@ const getPointerTargetGrid = (
|
||||
grids: Iterable<RegisteredGrid>,
|
||||
source: AbstractDraggable,
|
||||
pointer: { x: number; y: number },
|
||||
canAcceptDrop: (source: AbstractDraggable, targetGridId: string) => boolean,
|
||||
isTargetEligible: (source: AbstractDraggable, targetGridId: string) => boolean,
|
||||
) =>
|
||||
Array.from(grids)
|
||||
.filter((grid) => {
|
||||
@@ -1135,7 +1162,7 @@ const getPointerTargetGrid = (
|
||||
pointer.x <= rectangle.right &&
|
||||
pointer.y >= rectangle.top &&
|
||||
pointer.y <= rectangle.bottom &&
|
||||
canAcceptDrop(source, grid.id)
|
||||
isTargetEligible(source, grid.id)
|
||||
);
|
||||
})
|
||||
.toSorted((first, second) => {
|
||||
@@ -1217,9 +1244,17 @@ const getGridTargetData = (target: { data: Record<string, unknown> }): GridTarge
|
||||
return data.kind === GRID_TARGET_TYPE && data.sectionId ? (data as GridTargetData) : null;
|
||||
};
|
||||
|
||||
const getGridDepth = (sectionId: string, parentGridId: string | null) => {
|
||||
if (!parentGridId || sectionId === parentGridId) return 0;
|
||||
const parent = document.querySelector<HTMLElement>(`[data-grid-section-id="${CSS.escape(parentGridId)}"]`);
|
||||
const parentDepth = Number(parent?.dataset.gridDepth ?? 0);
|
||||
return Number.isFinite(parentDepth) ? parentDepth + 1 : 1;
|
||||
};
|
||||
const getGridGeometryKey = (state: TransactionalGridState<SectionGridPlacement>) =>
|
||||
JSON.stringify(
|
||||
state.grids
|
||||
.map((grid) => ({
|
||||
id: grid.id,
|
||||
columnCount: grid.columnCount,
|
||||
maxRowCount: grid.maxRowCount,
|
||||
parentGridId: grid.parentGridId,
|
||||
placements: grid.placements
|
||||
.map(({ id, x, y, w, h }) => ({ id, x, y, w, h }))
|
||||
.toSorted((first, second) => first.id.localeCompare(second.id)),
|
||||
}))
|
||||
.toSorted((first, second) => first.id.localeCompare(second.id)),
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
@@ -33,6 +33,7 @@ const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(n
|
||||
* content.
|
||||
*/
|
||||
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
const board = useRequiredBoard();
|
||||
const integrations = useIntegrations();
|
||||
const containersRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const [containers, setContainers] = useState<Map<string, HTMLElement>>(() => new Map());
|
||||
@@ -40,6 +41,10 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
const announce = useCallback((message: string) => {
|
||||
setAnnouncement((previous) => ({ id: previous.id + 1, message }));
|
||||
}, []);
|
||||
const liveEntryIds = useMemo(
|
||||
() => new Set([...board.items.map((item) => item.id), ...board.sections.map((section) => section.id)]),
|
||||
[board.items, board.sections],
|
||||
);
|
||||
|
||||
const acquireContainer = useCallback((id: string) => {
|
||||
const existing = containersRef.current.get(id);
|
||||
@@ -53,9 +58,17 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
return container;
|
||||
}, []);
|
||||
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
// A cross-grid move can briefly have no shell, but the entry still exists
|
||||
// on the board. Only remove containers whose board entity was deleted.
|
||||
let didPrune = false;
|
||||
for (const id of containersRef.current.keys()) {
|
||||
if (liveEntryIds.has(id)) continue;
|
||||
containersRef.current.delete(id);
|
||||
didPrune = true;
|
||||
}
|
||||
if (didPrune) setContainers(new Map(containersRef.current));
|
||||
}, [liveEntryIds]);
|
||||
|
||||
const value = useMemo<GridPortalHostContextValue>(
|
||||
() => ({
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
|
||||
export const getSectionGridPlacements = (board: Board, layoutId: string, sectionId: string): SectionGridPlacement[] => [
|
||||
...board.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout?.sectionId === sectionId
|
||||
? [
|
||||
{
|
||||
id: item.id,
|
||||
type: "item" as const,
|
||||
x: layout.xOffset,
|
||||
y: layout.yOffset,
|
||||
w: layout.width,
|
||||
h: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "container") return [];
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout?.parentSectionId === sectionId
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
type: "section" as const,
|
||||
x: layout.xOffset,
|
||||
y: layout.yOffset,
|
||||
w: layout.width,
|
||||
h: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
];
|
||||
|
||||
export const getSectionGridColumnCount = (board: Board, layoutId: string, sectionId: string): number | null => {
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) return null;
|
||||
if (section.kind === "container") {
|
||||
return section.layouts.find((layout) => layout.layoutId === layoutId)?.width ?? null;
|
||||
}
|
||||
|
||||
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
|
||||
return layout ? getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset)) : null;
|
||||
};
|
||||
@@ -92,6 +92,8 @@ describe("fixed grid item behavior", () => {
|
||||
mocks.editMode = true;
|
||||
mocks.maxRowCount = null;
|
||||
getWeatherMock().advancedOptions.title = null;
|
||||
getClockMock().xOffset = 1;
|
||||
getClockMock().yOffset = 0;
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
@@ -200,6 +202,21 @@ describe("fixed grid item behavior", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("stops keyboard editing when the rendered grid state is invalid", () => {
|
||||
getClockMock().xOffset = 0;
|
||||
renderWeather(root);
|
||||
|
||||
const entry = getEditorEntry(container);
|
||||
startKeyboardEditing(entry);
|
||||
act(() => entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
|
||||
|
||||
expect(entry.dataset.keyboardEditing).toBe("false");
|
||||
expect(mocks.commitSectionGrid).not.toHaveBeenCalled();
|
||||
expect(mocks.announce).toHaveBeenLastCalledWith(
|
||||
"widget.weather.name, column 1, row 1: item.moveResize.keyboard.stopped",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the whole tile as the focusable move surface with a visual drag affordance", () => {
|
||||
getWeatherMock().advancedOptions.title = " Balcony forecast ";
|
||||
renderWeather(root);
|
||||
@@ -286,6 +303,12 @@ const getWeatherMock = () => {
|
||||
return item;
|
||||
};
|
||||
|
||||
const getClockMock = () => {
|
||||
const item = mocks.items[1];
|
||||
if (!item) throw new Error("Expected the clock item mock to exist");
|
||||
return item;
|
||||
};
|
||||
|
||||
const getEditorEntry = (container: HTMLElement) => {
|
||||
const entry = container.querySelector<HTMLDivElement>("[data-editor-grid-entry]");
|
||||
if (!entry) throw new Error("Expected the fixed grid item to render an editor entry");
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getGridDepth } from "../grid-depth";
|
||||
|
||||
describe("grid depth", () => {
|
||||
test("follows the registered parent chain for deeply nested grids", () => {
|
||||
const parents = new Map<string, string | null>([
|
||||
["root", null],
|
||||
["first", "root"],
|
||||
["second", "first"],
|
||||
["third", "second"],
|
||||
]);
|
||||
|
||||
expect(getGridDepth("root", parents)).toBe(0);
|
||||
expect(getGridDepth("third", parents)).toBe(3);
|
||||
});
|
||||
|
||||
test("stops safely when malformed parent data contains a cycle", () => {
|
||||
const parents = new Map<string, string | null>([
|
||||
["first", "second"],
|
||||
["second", "first"],
|
||||
]);
|
||||
|
||||
expect(getGridDepth("first", parents)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ describe("board grid import boundary", () => {
|
||||
test("keeps every current dnd-kit runtime import inside the edit-only module", () => {
|
||||
const runtimeImports = getSourceFiles(nextjsSourceDirectory)
|
||||
.flatMap(getRuntimeImports)
|
||||
.filter(({ specifier }) => CURRENT_DND_KIT_PACKAGES.has(specifier))
|
||||
.filter(({ specifier }) => isCurrentDndKitImport(specifier))
|
||||
.map(({ filePath, kind, specifier }) => ({
|
||||
file: toSourceRelativePath(filePath),
|
||||
kind,
|
||||
@@ -40,6 +40,11 @@ describe("board grid import boundary", () => {
|
||||
kind: "static",
|
||||
specifier: "@dnd-kit/dom",
|
||||
},
|
||||
{
|
||||
file: "components/board/sections/grid/grid-editor.tsx",
|
||||
kind: "static",
|
||||
specifier: "@dnd-kit/dom/utilities",
|
||||
},
|
||||
{
|
||||
file: "components/board/sections/grid/grid-editor.tsx",
|
||||
kind: "static",
|
||||
@@ -104,7 +109,10 @@ describe("board grid import boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const CURRENT_DND_KIT_PACKAGES = new Set(["@dnd-kit/abstract", "@dnd-kit/collision", "@dnd-kit/dom", "@dnd-kit/react"]);
|
||||
const CURRENT_DND_KIT_PACKAGES = ["@dnd-kit/abstract", "@dnd-kit/collision", "@dnd-kit/dom", "@dnd-kit/react"];
|
||||
|
||||
const isCurrentDndKitImport = (specifier: string) =>
|
||||
CURRENT_DND_KIT_PACKAGES.some((packageName) => specifier === packageName || specifier.startsWith(`${packageName}/`));
|
||||
|
||||
interface RuntimeImport {
|
||||
filePath: string;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { act, createElement, useLayoutEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { BoardGridPortalHost, useBoardGridPortalHost } from "../grid-portal-host";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
board: {
|
||||
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "root" }] }],
|
||||
sections: [],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth/client", () => ({
|
||||
useIntegrations: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/context", () => ({
|
||||
useCurrentLayout: () => "desktop",
|
||||
useRequiredBoard: () => mocks.board,
|
||||
}));
|
||||
|
||||
describe("board grid portal host", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.board = {
|
||||
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "root" }] }],
|
||||
sections: [],
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test("keeps a portal across relocation and prunes it after permanent deletion", () => {
|
||||
const acquired: HTMLElement[] = [];
|
||||
|
||||
renderHost(root, true, 0, acquired);
|
||||
expect(getContainerCount(container)).toBe("1");
|
||||
|
||||
mocks.board = {
|
||||
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "other" }] }],
|
||||
sections: [],
|
||||
};
|
||||
renderHost(root, true, 1, acquired);
|
||||
expect(getContainerCount(container)).toBe("1");
|
||||
expect(new Set(acquired).size).toBe(1);
|
||||
|
||||
mocks.board = { items: [], sections: [] };
|
||||
renderHost(root, false, 2, acquired);
|
||||
expect(getContainerCount(container)).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
const renderHost = (root: Root, acquire: boolean, revision: number, acquired: HTMLElement[]) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(BoardGridPortalHost, null, createElement(PortalRegistryProbe, { acquire, revision, acquired })),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const PortalRegistryProbe = ({
|
||||
acquire,
|
||||
revision,
|
||||
acquired,
|
||||
}: {
|
||||
acquire: boolean;
|
||||
revision: number;
|
||||
acquired: HTMLElement[];
|
||||
}) => {
|
||||
const portalHost = useBoardGridPortalHost();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (acquire) acquired.push(portalHost.acquireContainer("weather"));
|
||||
}, [acquire, acquired, portalHost, revision]);
|
||||
|
||||
return createElement("span", { "data-container-count": String(portalHost.containers.size) });
|
||||
};
|
||||
|
||||
const getContainerCount = (container: HTMLElement) =>
|
||||
container.querySelector<HTMLElement>("[data-container-count]")?.dataset.containerCount;
|
||||
Reference in new issue
Block a user