fix: address board grid review
This commit is contained in:
54 files changed
+657
-323
No files matched your search
@@ -1,9 +1,5 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Users
|
||||
|
||||
Homarr serves people and teams who run self-hosted applications and want one dependable dashboard for launching apps, monitoring services, and viewing live operational data. They use it across wall displays, desktops, tablets, and smaller devices, often returning to the same board many times per day.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Glossary
|
||||
|
||||
In the glossary, you'll learn the different components and their names of Homarr.
|
||||
This glossary explains Homarr's main components and the terms used for them.
|
||||
|
||||
| Term | Definition |
|
||||
| ----------- | --------------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -155,6 +155,7 @@ Resize tiles from any edge or corner handle.
|
||||
On a touch screen, press and hold the tile before moving it; resize handles support direct touch input.
|
||||
Keyboard users can focus a tile, press Enter or Space to start editing, use the arrow keys to move it, and use Shift plus the arrow keys to resize it.
|
||||
Press Enter, Space, or Escape to stop keyboard editing.
|
||||
Keyboard moves and resizes apply immediately; Escape stops keyboard editing without undoing those changes.
|
||||
During a pointer or touch interaction, Escape cancels the move or resize and restores the layout from before that interaction.
|
||||
The **Move / resize item** action in the edit menu remains available for precise numeric input.
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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}')`);
|
||||
}
|
||||
|
||||
|
||||
@@ -915,24 +915,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1115,27 +1115,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();
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nova secció dinàmica",
|
||||
"remove": "Elimina la secció dinàmica"
|
||||
"create": "Nou contenidor",
|
||||
"remove": "Elimina el contenidor"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "新建动态部分",
|
||||
"remove": "删除动态部分"
|
||||
"create": "新建容器",
|
||||
"remove": "删除容器"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "删除动态部分",
|
||||
"message": "你确定你要删除这个动态部分?项目将被移动到父节的相同位置。"
|
||||
"title": "删除容器",
|
||||
"message": "你确定要删除这个容器吗?项目将被移动到父级区域的相同位置。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nová dynamická sekce",
|
||||
"remove": "Odstranit dynamickou sekci"
|
||||
"create": "Nový kontejner",
|
||||
"remove": "Odstranit kontejner"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Ny dynamisk sektion",
|
||||
"remove": "Fjern dynamisk sektion"
|
||||
"create": "Ny container",
|
||||
"remove": "Fjern container"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Neuer dynamischer Abschnitt",
|
||||
"remove": "Dynamischen Abschnitt entfernen"
|
||||
"create": "Neuer Container",
|
||||
"remove": "Container entfernen"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Neuer dynamischer Abschnitt",
|
||||
"remove": "Dynamischen Abschnitt entfernen"
|
||||
"create": "Neuer Container",
|
||||
"remove": "Container entfernen"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "",
|
||||
"remove": ""
|
||||
"create": "Νέο κοντέινερ",
|
||||
"remove": "Κατάργηση κοντέινερ"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
"title": "Κατάργηση κοντέινερ",
|
||||
"message": "Είστε βέβαιοι ότι θέλετε να καταργήσετε αυτό το κοντέινερ; Τα στοιχεία θα μετακινηθούν στην ίδια θέση στη γονική ενότητα."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nueva sección dinámica",
|
||||
"remove": "Eliminar sección dinámica"
|
||||
"create": "Nuevo contenedor",
|
||||
"remove": "Eliminar contenedor"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Uus dünaamiline sektsioon",
|
||||
"remove": "Eemalda dünaamiline sektsioon"
|
||||
"create": "Uus konteiner",
|
||||
"remove": "Eemalda konteiner"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Uusi dynaaminen osio",
|
||||
"remove": ""
|
||||
"create": "Uusi kontti",
|
||||
"remove": "Poista kontti"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nouvelle section dynamique",
|
||||
"remove": "Supprimer la section dynamique"
|
||||
"create": "Nouveau conteneur",
|
||||
"remove": "Supprimer le conteneur"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "מדור דינמי חדש",
|
||||
"remove": "הסר קטע דינמי"
|
||||
"create": "מיכל חדש",
|
||||
"remove": "הסר מיכל"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "הסר קטע דינמי",
|
||||
"message": "האם אתה בטוח שברצונך להסיר את הקטע הדינמי הזה? פריטים יועברו לאותו מיקום במדור האב."
|
||||
"title": "הסר מיכל",
|
||||
"message": "האם אתה בטוח שברצונך להסיר את המיכל הזה? פריטים יועברו לאותו מיקום במדור האב."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nova dinamička sekcija",
|
||||
"remove": "Ukloni dinamičku sekciju"
|
||||
"create": "Novi kontejner",
|
||||
"remove": "Ukloni kontejner"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,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": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nuova sezione dinamica",
|
||||
"remove": "Rimuovi sezione dinamica"
|
||||
"create": "Nuovo container",
|
||||
"remove": "Rimuovi container"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "新しいダイナミックセクション",
|
||||
"remove": "ダイナミックセクションを削除する"
|
||||
"create": "新しいコンテナー",
|
||||
"remove": "コンテナーを削除する"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "ダイナミックセクションの削除",
|
||||
"message": "この動的セクションを削除してもよろしいですか?アイテムは親セクションの同じ場所に移動されます。"
|
||||
"title": "コンテナーの削除",
|
||||
"message": "このコンテナーを削除してもよろしいですか?アイテムは親セクションの同じ場所に移動されます。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "새 동적 섹션",
|
||||
"remove": "동적 섹션 제거"
|
||||
"create": "새 컨테이너",
|
||||
"remove": "컨테이너 제거"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "동적 섹션 제거",
|
||||
"message": "이 동적 섹션을 제거하시겠습니까? 항목은 상위 섹션의 동일한 위치로 이동합니다."
|
||||
"title": "컨테이너 제거",
|
||||
"message": "이 컨테이너를 제거하시겠습니까? 항목은 상위 섹션의 동일한 위치로 이동합니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nauja dinaminė sekcija",
|
||||
"remove": "Pašalinti dinaminę sekciją"
|
||||
"create": "Naujas konteineris",
|
||||
"remove": "Pašalinti konteinerį"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Jauna dinamiskā sadaļa",
|
||||
"remove": "Noņemt dinamisko sadaļu"
|
||||
"create": "Jauns konteiners",
|
||||
"remove": "Noņemt konteineru"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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ļā."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nieuwe dynamische sectie",
|
||||
"remove": "Dynamische sectie verwijderen"
|
||||
"create": "Nieuwe container",
|
||||
"remove": "Container verwijderen"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Ny dynamisk seksjon",
|
||||
"remove": "Fjern dynamisk seksjon"
|
||||
"create": "Ny beholder",
|
||||
"remove": "Fjern beholder"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nowa sekcja dynamiczna",
|
||||
"remove": "Usuń sekcję dynamiczną"
|
||||
"create": "Nowy kontener",
|
||||
"remove": "Usuń kontener"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,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": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,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": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "",
|
||||
"remove": ""
|
||||
"create": "Container nou",
|
||||
"remove": "Elimină containerul"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Новый динамический элемент",
|
||||
"remove": "Удалить динамический элемент"
|
||||
"create": "Новый контейнер",
|
||||
"remove": "Удалить контейнер"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "Удалить динамический элемент",
|
||||
"message": "Вы уверены, что хотите удалить этот динамический элемент? Содержимое будет перемещено в то же место в родительском элементе."
|
||||
"title": "Удалить контейнер",
|
||||
"message": "Вы уверены, что хотите удалить этот контейнер? Содержимое будет перемещено в то же место в родительском элементе."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nová dynamická sekcia",
|
||||
"remove": "Nová dynamická sekcia"
|
||||
"create": "Nový kontajner",
|
||||
"remove": "Odstrániť kontajner"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nova dinamična vsebina",
|
||||
"remove": "Odstrani dinamični odsek"
|
||||
"create": "Nov vsebnik",
|
||||
"remove": "Odstrani vsebnik"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Ny dynamisk sektion",
|
||||
"remove": ""
|
||||
"create": "Ny behållare",
|
||||
"remove": "Ta bort behållare"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,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": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Нова динамічна секція",
|
||||
"remove": "Видалити динамічну секцію"
|
||||
"create": "Новий контейнер",
|
||||
"remove": "Видалити контейнер"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "Видалити динамічний розділ",
|
||||
"message": "Ви впевнені, що хочете видалити цей динамічний розділ? Елементи будуть переміщені в те саме місце в батьківському розділі."
|
||||
"title": "Видалити контейнер",
|
||||
"message": "Ви впевнені, що хочете видалити цей контейнер? Елементи будуть переміщені в те саме місце в батьківському розділі."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,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": {
|
||||
@@ -1566,8 +1566,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1551,8 +1551,8 @@
|
||||
"section": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "創建動態區段",
|
||||
"remove": "移除動態區段"
|
||||
"create": "新增容器",
|
||||
"remove": "移除容器"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,8 +1566,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "移除動態區段",
|
||||
"message": "確認要移除此動態區段嗎?項目將移動到父區段的相同位置"
|
||||
"title": "移除容器",
|
||||
"message": "確認要移除此容器嗎?項目將移動到父區段的相同位置"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user