fix: address board grid review

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:06 +02:00
1 parent cddae6719e
commit 28ac23e66b
51 files changed
+647 -278

No files matched your search

-4
View File
@@ -1,9 +1,5 @@
# Product
## Register
product
## Users
Homarr is used by self-hosters and administrators who assemble dashboards for services running across home labs and private infrastructure. They need dense, reliable controls and status at a glance, with enough guidance to configure advanced features without hiding how they work.
@@ -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;
+1 -1
View File
@@ -465,7 +465,6 @@ describe("Board grid", () => {
await expect(containerSection.locator("xpath=ancestor::aside[1]")).toHaveAttribute("data-board-gutter", "left");
await editToggle.click();
await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3);
const mainGridBox = await expectBoundingBoxAsync(mainGrid);
await dragHandleToGridPositionAsync(page, containerGrip, containerSection, mainGrid, 2, 0, canvasScale);
await expect(containerSection.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute(
"data-grid-section-id",
@@ -481,6 +480,7 @@ describe("Board grid", () => {
await secondItem.locator("[data-grid-item-content]").evaluate((element) => {
element.setAttribute("data-e2e-portal-instance", "second-item-content");
});
const mainGridBox = await expectBoundingBoxAsync(mainGrid);
await dragLocatorToAsync(
page,
secondItem,
+3 -2
View File
@@ -10,8 +10,9 @@ export const createHomarrContainer = (
};
} = {},
) => {
const image = process.env.HOMARR_E2E_IMAGE?.trim() || "homarr-e2e";
if (!process.env.CI) {
const configuredImage = process.env.HOMARR_E2E_IMAGE?.trim();
const image = configuredImage || "homarr-e2e";
if (!process.env.CI && !configuredImage) {
throw new Error(`This test should only be run in CI or with a local Homarr image (configured: '${image}')`);
}
+35 -38
View File
@@ -925,24 +925,24 @@ export const boardRouter = createTRPCRouter({
})),
);
if (addedSections.some((section) => section.kind === "container")) {
await transaction.insert(schema.sectionLayouts).values(
addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
),
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
if (sectionLayoutsToInsert.length > 0) {
await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
}
}
@@ -1125,27 +1125,24 @@ export const boardRouter = createTRPCRouter({
)
.run();
if (addedSections.some((section) => section.kind === "container")) {
transaction
.insert(sectionLayouts)
.values(
addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
),
)
.run();
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map(
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
if (sectionLayoutsToInsert.length > 0) {
transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
}
}
@@ -1011,6 +1011,43 @@ describe("saveBoard should save full board", () => {
height: 5,
});
});
it("should add a container without layouts", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId: rootSectionId } = await createFullBoardAsync(db, "default");
const containerId = createId();
await caller.saveBoard({
id: boardId,
sections: [
{
id: rootSectionId,
kind: "empty",
xOffset: 0,
yOffset: 0,
},
{
id: containerId,
kind: "container",
collapsed: false,
options: {},
layouts: [],
},
],
items: [],
});
expect(
await db.query.sections.findFirst({
where: eq(sections.id, containerId),
}),
).toMatchObject({ id: containerId, kind: "container" });
expect(
await db.query.sectionLayouts.findMany({
where: eq(sectionLayouts.sectionId, containerId),
}),
).toEqual([]);
});
it("should add item when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Nova secció dinàmica",
"remove": "Elimina la secció dinàmica"
"create": "Nou contenidor",
"remove": "Elimina el contenidor"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Elimina la secció dinàmica",
"message": "Esteu segur que voleu eliminar aquesta secció dinàmica? Els elements es mouran a la mateixa ubicació dins la secció pare."
"title": "Elimina el contenidor",
"message": "Esteu segur que voleu eliminar aquest contenidor? Els elements es mouran a la mateixa ubicació dins la secció pare."
}
}
},
+4 -4
View File
@@ -1551,8 +1551,8 @@
"section": {
"container": {
"action": {
"create": "新建动态部分",
"remove": "删除动态部分"
"create": "新建容器",
"remove": "删除容器"
},
"option": {
"title": {
@@ -1566,8 +1566,8 @@
}
},
"remove": {
"title": "删除动态部分",
"message": "你确定你要删除这个动态部分?项目将被移动到父节的相同位置。"
"title": "删除容器",
"message": "你确定要删除这个容器吗?项目将被移动到父级区域的相同位置。"
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Nová dynamická sekce",
"remove": "Odstranit dynamickou sekci"
"create": "Nový kontejner",
"remove": "Odstranit kontejner"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Odstranit dynamickou sekci",
"message": "Jste si jisti, že chcete odstranit tuto dynamickou sekci? Položky budou přesunuty na stejné místo v nadřazené sekci."
"title": "Odstranit kontejner",
"message": "Jste si jisti, že chcete odstranit tento kontejner? Položky budou přesunuty na stejné místo v nadřazené sekci."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Ny dynamisk sektion",
"remove": "Fjern dynamisk sektion"
"create": "Ny container",
"remove": "Fjern container"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Fjern dynamisk sektion",
"message": "Er du sikker på at du vil fjerne dette dynamiske afsnit? Elementer vil blive flyttet på samme sted i det overordnede afsnit."
"title": "Fjern container",
"message": "Er du sikker på, at du vil fjerne denne container? Elementer vil blive flyttet til samme sted i det overordnede afsnit."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Neuer dynamischer Abschnitt",
"remove": "Dynamischen Abschnitt entfernen"
"create": "Neuer Container",
"remove": "Container entfernen"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Dynamischen Abschnitt entfernen",
"message": "Möchten Sie diesen dynamischen Abschnitt wirklich entfernen? Die Elemente werden an die gleiche Position im übergeordneten Abschnitt verschoben."
"title": "Container entfernen",
"message": "Möchten Sie diesen Container wirklich entfernen? Die Elemente werden an die gleiche Position im übergeordneten Bereich verschoben."
}
}
},
+4 -4
View File
@@ -1400,8 +1400,8 @@
"section": {
"container": {
"action": {
"create": "Neuer dynamischer Abschnitt",
"remove": "Dynamischen Abschnitt entfernen"
"create": "Neuer Container",
"remove": "Container entfernen"
},
"option": {
"title": {
@@ -1415,8 +1415,8 @@
}
},
"remove": {
"title": "Dynamischen Abschnitt entfernen",
"message": "Möchten Sie diesen dynamischen Abschnitt wirklich entfernen? Die Elemente werden an die gleiche Position im übergeordneten Abschnitt verschoben."
"title": "Container entfernen",
"message": "Möchten Sie diesen Container wirklich entfernen? Die Elemente werden an die gleiche Position im übergeordneten Bereich verschoben."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "",
"remove": ""
"create": "Νέο κοντέινερ",
"remove": "Κατάργηση κοντέινερ"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "",
"message": ""
"title": "Κατάργηση κοντέινερ",
"message": "Είστε βέβαιοι ότι θέλετε να καταργήσετε αυτό το κοντέινερ; Τα στοιχεία θα μετακινηθούν στην ίδια θέση στη γονική ενότητα."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Nueva sección dinámica",
"remove": "Eliminar sección dinámica"
"create": "Nuevo contenedor",
"remove": "Eliminar contenedor"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Eliminar sección dinámica",
"message": "¿Estás seguro de que deseas eliminar esta sección dinámica? Los artículos se mantendrán en la misma ubicación."
"title": "Eliminar contenedor",
"message": "¿Estás seguro de que deseas eliminar este contenedor? Los elementos se mantendrán en la misma ubicación."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Uus dünaamiline sektsioon",
"remove": "Eemalda dünaamiline sektsioon"
"create": "Uus konteiner",
"remove": "Eemalda konteiner"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Eemalda dünaamiline sektsioon",
"message": "Kas oled kindel, et soovid selle dünaamilise sektsiooni eemaldada? Üksused teisaldatakse ülemsektsiooni samasse asukohta."
"title": "Eemalda konteiner",
"message": "Kas oled kindel, et soovid selle konteineri eemaldada? Üksused teisaldatakse ülemsektsiooni samasse asukohta."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Uusi dynaaminen osio",
"remove": ""
"create": "Uusi kontti",
"remove": "Poista kontti"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "",
"message": "Haluatko varmasti poistaa tämän dynaamisen osion? Kohteet siirretään samaan paikkaan yläosiossa."
"title": "Poista kontti",
"message": "Haluatko varmasti poistaa tämän kontin? Kohteet siirretään samaan paikkaan yläosiossa."
}
}
},
+4 -4
View File
@@ -1399,8 +1399,8 @@
"section": {
"container": {
"action": {
"create": "Nouvelle section dynamique",
"remove": "Supprimer la section dynamique"
"create": "Nouveau conteneur",
"remove": "Supprimer le conteneur"
},
"option": {
"title": {
@@ -1414,8 +1414,8 @@
}
},
"remove": {
"title": "Supprimer la section dynamique",
"message": "Êtes-vous sûr de vouloir supprimer cette section dynamique ? Les éléments seront déplacés au même emplacement dans la section parente."
"title": "Supprimer le conteneur",
"message": "Êtes-vous sûr de vouloir supprimer ce conteneur ? Les éléments seront déplacés au même emplacement dans la section parente."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "מדור דינמי חדש",
"remove": "הסר קטע דינמי"
"create": "מיכל חדש",
"remove": "הסר מיכל"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "הסר קטע דינמי",
"message": "האם אתה בטוח שברצונך להסיר את הקטע הדינמי הזה? פריטים יועברו לאותו מיקום במדור האב."
"title": "הסר מיכל",
"message": "האם אתה בטוח שברצונך להסיר את המיכל הזה? פריטים יועברו לאותו מיקום במדור האב."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Nova dinamička sekcija",
"remove": "Ukloni dinamičku sekciju"
"create": "Novi kontejner",
"remove": "Ukloni kontejner"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Ukloni dinamičku sekciju",
"message": "Jeste li sigurni da želite ukloniti ovu dinamičku sekciju? Stavke će biti premještene na isto mjesto u nadređenoj sekciji."
"title": "Ukloni kontejner",
"message": "Jeste li sigurni da želite ukloniti ovaj kontejner? Stavke će biti premještene na isto mjesto u nadređenoj sekciji."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Új dinamikus szakasz",
"remove": "A dinamikus szakasz eltávolítása"
"create": "Új konténer",
"remove": "A konténer eltávolítása"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Dinamikus szakasz eltávolítása",
"message": "Biztosan el akarja távolítani ezt a dinamikus részt? Az elemek a szülő rész ugyanazon helyére kerülnek át."
"title": "Konténer eltávolítása",
"message": "Biztosan el akarja távolítani ezt a konténert? Az elemek a szülő rész ugyanazon helyére kerülnek át."
}
}
},
+4 -4
View File
@@ -1400,8 +1400,8 @@
"section": {
"container": {
"action": {
"create": "Nuova sezione dinamica",
"remove": "Rimuovi sezione dinamica"
"create": "Nuovo container",
"remove": "Rimuovi container"
},
"option": {
"title": {
@@ -1415,8 +1415,8 @@
}
},
"remove": {
"title": "Rimuovi sezione dinamica",
"message": "Vuoi davvero rimuovere questa sezione dinamica? Gli elementi verranno spostati nella stessa posizione nella sezione padre."
"title": "Rimuovi container",
"message": "Vuoi davvero rimuovere questo container? Gli elementi verranno spostati nella stessa posizione nella sezione padre."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "新しいダイナミックセクション",
"remove": "ダイナミックセクションを削除する"
"create": "新しいコンテナー",
"remove": "コンテナーを削除する"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "ダイナミックセクションの削除",
"message": "この動的セクションを削除してもよろしいですか?アイテムは親セクションの同じ場所に移動されます。"
"title": "コンテナーの削除",
"message": "このコンテナーを削除してもよろしいですか?アイテムは親セクションの同じ場所に移動されます。"
}
}
},
+4 -4
View File
@@ -1400,8 +1400,8 @@
"section": {
"container": {
"action": {
"create": "새 동적 섹션",
"remove": "동적 섹션 제거"
"create": "새 컨테이너",
"remove": "컨테이너 제거"
},
"option": {
"title": {
@@ -1415,8 +1415,8 @@
}
},
"remove": {
"title": "동적 섹션 제거",
"message": "이 동적 섹션을 제거하시겠습니까? 항목은 상위 섹션의 동일한 위치로 이동합니다."
"title": "컨테이너 제거",
"message": "이 컨테이너를 제거하시겠습니까? 항목은 상위 섹션의 동일한 위치로 이동합니다."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Nauja dinaminė sekcija",
"remove": "Pašalinti dinaminę sekciją"
"create": "Naujas konteineris",
"remove": "Pašalinti konteinerį"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Pašalinti dinaminę sekciją",
"message": "Ar tikrai norite pašalinti šią dinaminę sekciją? Elementai bus perkelti į tą pačią vietą pirminėje sekcijoje."
"title": "Pašalinti konteinerį",
"message": "Ar tikrai norite pašalinti šį konteinerį? Elementai bus perkelti į tą pačią vietą pirminėje sekcijoje."
}
}
},
+4 -4
View File
@@ -1356,8 +1356,8 @@
"section": {
"container": {
"action": {
"create": "Jauna dinamiskā sadaļa",
"remove": "Noņemt dinamisko sadaļu"
"create": "Jauns konteiners",
"remove": "Noņemt konteineru"
},
"option": {
"title": {
@@ -1371,8 +1371,8 @@
}
},
"remove": {
"title": "Noņemt dinamisko sadaļu",
"message": "Vai esat pārliecināts, ka vēlaties noņemt šo dinamisko sadaļu? Vienumi tiks pārvietoti uz to pašu atrašanās vietu vecāku sadaļā."
"title": "Noņemt konteineru",
"message": "Vai esat pārliecināts, ka vēlaties noņemt šo konteineru? Vienumi tiks pārvietoti uz to pašu atrašanās vietu vecāku sadaļā."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Nieuwe dynamische sectie",
"remove": "Dynamische sectie verwijderen"
"create": "Nieuwe container",
"remove": "Container verwijderen"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Dynamische sectie verwijderen",
"message": "Weet je zeker dat je deze dynamische sectie wilt verwijderen? Items worden verplaatst naar dezelfde locatie in de bovenliggende sectie."
"title": "Container verwijderen",
"message": "Weet je zeker dat je deze container wilt verwijderen? Items worden verplaatst naar dezelfde locatie in de bovenliggende sectie."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Ny dynamisk seksjon",
"remove": "Fjern dynamisk seksjon"
"create": "Ny beholder",
"remove": "Fjern beholder"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Fjern dynamisk seksjon",
"message": "Er du sikker på at du vil fjerne denne dynamiske delen? Elementer vil bli flyttet til samme sted i overordnet seksjon."
"title": "Fjern beholder",
"message": "Er du sikker på at du vil fjerne denne beholderen? Elementer vil bli flyttet til samme sted i overordnet seksjon."
}
}
},
+4 -4
View File
@@ -1400,8 +1400,8 @@
"section": {
"container": {
"action": {
"create": "Nowa sekcja dynamiczna",
"remove": "Usuń sekcję dynamiczną"
"create": "Nowy kontener",
"remove": "Usuń kontener"
},
"option": {
"title": {
@@ -1415,8 +1415,8 @@
}
},
"remove": {
"title": "Usuń sekcję dynamiczną",
"message": "Czy na pewno chcesz usunąć tę sekcję dynamiczną? Elementy zostaną przeniesione w tej samej lokalizacji w sekcji nadrzędnej."
"title": "Usuń kontener",
"message": "Czy na pewno chcesz usunąć ten kontener? Elementy zostaną przeniesione w tej samej lokalizacji w sekcji nadrzędnej."
}
}
},
+4 -4
View File
@@ -1398,8 +1398,8 @@
"section": {
"container": {
"action": {
"create": "Nova seção dinâmica",
"remove": "Remover seção dinâmica"
"create": "Novo contêiner",
"remove": "Remover contêiner"
},
"option": {
"title": {
@@ -1413,8 +1413,8 @@
}
},
"remove": {
"title": "Remover seção dinâmica",
"message": "Tem certeza de que deseja remover esta seção dinâmica? Os itens serão movidos para o mesmo local na seção pai."
"title": "Remover contêiner",
"message": "Tem certeza de que deseja remover este contêiner? Os itens serão movidos para o mesmo local na seção pai."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Nova seção dinâmica",
"remove": "Remover seção dinâmica"
"create": "Novo contêiner",
"remove": "Remover contêiner"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Remover seção dinâmica",
"message": "Tem certeza de que deseja remover esta seção dinâmica? Os itens serão movidos para o mesmo local na seção pai."
"title": "Remover contêiner",
"message": "Tem certeza de que deseja remover este contêiner? Os itens serão movidos para o mesmo local na seção pai."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "",
"remove": ""
"create": "Container nou",
"remove": "Elimină containerul"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "",
"message": ""
"title": "Elimină containerul",
"message": "Sigur doriți să eliminați acest container? Elementele vor fi mutate în aceeași poziție din secțiunea părinte."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Новый динамический элемент",
"remove": "Удалить динамический элемент"
"create": "Новый контейнер",
"remove": "Удалить контейнер"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Удалить динамический элемент",
"message": "Вы уверены, что хотите удалить этот динамический элемент? Содержимое будет перемещено в то же место в родительском элементе."
"title": "Удалить контейнер",
"message": "Вы уверены, что хотите удалить этот контейнер? Содержимое будет перемещено в то же место в родительском элементе."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Nová dynamická sekcia",
"remove": "Nová dynamická sekcia"
"create": "Nový kontajner",
"remove": "Odstrániť kontajner"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Nová dynamická sekcia",
"message": "Naozaj chcete odstrániť túto dynamickú sekciu? Položky sa presunú na rovnaké miesto v nadradenej sekcii."
"title": "Odstrániť kontajner",
"message": "Naozaj chcete odstrániť tento kontajner? Položky sa presunú na rovnaké miesto v nadradenej sekcii."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Nova dinamična vsebina",
"remove": "Odstrani dinamični odsek"
"create": "Nov vsebnik",
"remove": "Odstrani vsebnik"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Odstrani dinamični odsek",
"message": "Ali ste prepričani, da želite odstraniti ta dinamični odsek? Elementi bodo premaknjeni na isto mesto v nadrejenem odseku."
"title": "Odstrani vsebnik",
"message": "Ali ste prepričani, da želite odstraniti ta vsebnik? Elementi bodo premaknjeni na isto mesto v nadrejenem odseku."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Ny dynamisk sektion",
"remove": ""
"create": "Ny behållare",
"remove": "Ta bort behållare"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "",
"message": ""
"title": "Ta bort behållare",
"message": "Är du säker på att du vill ta bort den här behållaren? Objekten flyttas till samma plats i den överordnade sektionen."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Yeni Dinamik Bölüm",
"remove": "Dinamik bölümü kaldır"
"create": "Yeni konteyner",
"remove": "Konteyneri kaldır"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Dinamik bölümü kaldır",
"message": "Bu dinamik bölümü kaldırmak istediğinizden emin misiniz? Öğeler üst bölümdeki aynı konuma taşınacaktır."
"title": "Konteyneri kaldır",
"message": "Bu konteyneri kaldırmak istediğinizden emin misiniz? Öğeler üst bölümdeki aynı konuma taşınacaktır."
}
}
},
+4 -4
View File
@@ -1356,8 +1356,8 @@
"section": {
"container": {
"action": {
"create": "Нова динамічна секція",
"remove": "Видалити динамічну секцію"
"create": "Новий контейнер",
"remove": "Видалити контейнер"
},
"option": {
"title": {
@@ -1371,8 +1371,8 @@
}
},
"remove": {
"title": "Видалити динамічний розділ",
"message": "Ви впевнені, що хочете видалити цей динамічний розділ? Елементи будуть переміщені в те саме місце в батьківському розділі."
"title": "Видалити контейнер",
"message": "Ви впевнені, що хочете видалити цей контейнер? Елементи будуть переміщені в те саме місце в батьківському розділі."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "Phần động mới",
"remove": "Xóa phần động"
"create": "Vùng chứa mới",
"remove": "Xóa vùng chứa"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "Xóa phần động",
"message": "Bạn có chắc chắn muốn xóa phần động này không? Các mục sẽ được chuyển đến cùng vị trí trong phần cha."
"title": "Xóa vùng chứa",
"message": "Bạn có chắc chắn muốn xóa vùng chứa này không? Các mục sẽ được chuyển đến cùng vị trí trong phần cha."
}
}
},
+4 -4
View File
@@ -1346,8 +1346,8 @@
"section": {
"container": {
"action": {
"create": "創建動態區段",
"remove": "移除動態區段"
"create": "新增容器",
"remove": "移除容器"
},
"option": {
"title": {
@@ -1361,8 +1361,8 @@
}
},
"remove": {
"title": "移除動態區段",
"message": "確認要移除此動態區段嗎?項目將移動到父區段的相同位置"
"title": "移除容器",
"message": "確認要移除此容器嗎?項目將移動到父區段的相同位置"
}
}
},