diff --git a/PRODUCT.md b/PRODUCT.md index 8b4a6c01a..adcf7f24e 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -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. diff --git a/apps/nextjs/src/components/board/sections/container/actions/container-actions.spec.ts b/apps/nextjs/src/components/board/sections/container/actions/container-actions.spec.ts index 04f17354b..6dac9e6cc 100644 --- a/apps/nextjs/src/components/board/sections/container/actions/container-actions.spec.ts +++ b/apps/nextjs/src/components/board/sections/container/actions/container-actions.spec.ts @@ -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); }); }); diff --git a/apps/nextjs/src/components/board/sections/container/actions/remove-container.ts b/apps/nextjs/src/components/board/sections/container/actions/remove-container.ts index 5e4a53b9c..8562d0f9a 100644 --- a/apps/nextjs/src/components/board/sections/container/actions/remove-container.ts +++ b/apps/nextjs/src/components/board/sections/container/actions/remove-container.ts @@ -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(); + 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(); @@ -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; -}; diff --git a/apps/nextjs/src/components/board/sections/container/container-actions.ts b/apps/nextjs/src/components/board/sections/container/container-actions.ts index 7bbc975a8..c9545ef9d 100644 --- a/apps/nextjs/src/components/board/sections/container/container-actions.ts +++ b/apps/nextjs/src/components/board/sections/container/container-actions.ts @@ -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; + newOptions: ContainerSectionOptions; } export const useContainerActions = () => { diff --git a/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx b/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx index d1caa633a..b52aba851 100644 --- a/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx +++ b/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx @@ -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; + 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, { diff --git a/apps/nextjs/src/components/board/sections/grid/grid-depth.ts b/apps/nextjs/src/components/board/sections/grid/grid-depth.ts new file mode 100644 index 000000000..7228b39aa --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/grid-depth.ts @@ -0,0 +1,14 @@ +export const getGridDepth = (gridId: string, parentGridById: ReadonlyMap) => { + 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; + } +}; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css index 5c2b9211a..6c9a1f1a9 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -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); } diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx index 6123b5abf..d401fc0d5 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -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(null); const [overlayZoom, setOverlayZoom] = useState(1); + const parentGridById = useMemo( + () => + new Map( + 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(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, 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 }): 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(`[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) => + 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)), + ); diff --git a/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx b/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx index b45da998f..c2146ea50 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx @@ -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(n * content. */ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => { + const board = useRequiredBoard(); const integrations = useIntegrations(); const containersRef = useRef>(new Map()); const [containers, setContainers] = useState>(() => 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( () => ({ diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid-placements.ts b/apps/nextjs/src/components/board/sections/grid/section-grid-placements.ts new file mode 100644 index 000000000..bd99a921d --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/section-grid-placements.ts @@ -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; +}; diff --git a/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts index 72fee5967..2ae6fcf60 100644 --- a/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts +++ b/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts @@ -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("[data-editor-grid-entry]"); if (!entry) throw new Error("Expected the fixed grid item to render an editor entry"); diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-depth.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-depth.spec.ts new file mode 100644 index 000000000..8f15f29b1 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/test/grid-depth.spec.ts @@ -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([ + ["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([ + ["first", "second"], + ["second", "first"], + ]); + + expect(getGridDepth("first", parents)).toBe(1); + }); +}); diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts index d1732a826..55ecae127 100644 --- a/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts +++ b/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts @@ -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; diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-portal-host.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-portal-host.spec.ts new file mode 100644 index 000000000..f5b95d969 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/grid/test/grid-portal-host.spec.ts @@ -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("[data-container-count]")?.dataset.containerCount; diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts index b183840e6..4479dc264 100644 --- a/e2e/board-grid.spec.ts +++ b/e2e/board-grid.spec.ts @@ -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, diff --git a/e2e/shared/create-homarr-container.ts b/e2e/shared/create-homarr-container.ts index d1e2cee7c..80e5646e0 100644 --- a/e2e/shared/create-homarr-container.ts +++ b/e2e/shared/create-homarr-container.ts @@ -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}')`); } diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index a0ef37559..617d7263a 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -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 => ({ - 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 => ({ + 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 => ({ - 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 => ({ + 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(); } } diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index 17a827f8e..2281c2f94 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -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(); diff --git a/packages/translation/src/lang/ca.json b/packages/translation/src/lang/ca.json index 0b769d5ef..d1b3d026f 100644 --- a/packages/translation/src/lang/ca.json +++ b/packages/translation/src/lang/ca.json @@ -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." } } }, diff --git a/packages/translation/src/lang/cn.json b/packages/translation/src/lang/cn.json index cc11b7857..1203373dc 100644 --- a/packages/translation/src/lang/cn.json +++ b/packages/translation/src/lang/cn.json @@ -1551,8 +1551,8 @@ "section": { "container": { "action": { - "create": "新建动态部分", - "remove": "删除动态部分" + "create": "新建容器", + "remove": "删除容器" }, "option": { "title": { @@ -1566,8 +1566,8 @@ } }, "remove": { - "title": "删除动态部分", - "message": "你确定你要删除这个动态部分?项目将被移动到父节的相同位置。" + "title": "删除容器", + "message": "你确定要删除这个容器吗?项目将被移动到父级区域的相同位置。" } } }, diff --git a/packages/translation/src/lang/cs.json b/packages/translation/src/lang/cs.json index 3f037d019..fd4c6159d 100644 --- a/packages/translation/src/lang/cs.json +++ b/packages/translation/src/lang/cs.json @@ -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." } } }, diff --git a/packages/translation/src/lang/da.json b/packages/translation/src/lang/da.json index 5d700be90..783ff7cb9 100644 --- a/packages/translation/src/lang/da.json +++ b/packages/translation/src/lang/da.json @@ -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." } } }, diff --git a/packages/translation/src/lang/de-CH.json b/packages/translation/src/lang/de-CH.json index 187375ae4..84064075e 100644 --- a/packages/translation/src/lang/de-CH.json +++ b/packages/translation/src/lang/de-CH.json @@ -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." } } }, diff --git a/packages/translation/src/lang/de.json b/packages/translation/src/lang/de.json index a5883674f..7ac28559f 100644 --- a/packages/translation/src/lang/de.json +++ b/packages/translation/src/lang/de.json @@ -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." } } }, diff --git a/packages/translation/src/lang/el.json b/packages/translation/src/lang/el.json index dcd98136e..6945b093e 100644 --- a/packages/translation/src/lang/el.json +++ b/packages/translation/src/lang/el.json @@ -1346,8 +1346,8 @@ "section": { "container": { "action": { - "create": "", - "remove": "" + "create": "Νέο κοντέινερ", + "remove": "Κατάργηση κοντέινερ" }, "option": { "title": { @@ -1361,8 +1361,8 @@ } }, "remove": { - "title": "", - "message": "" + "title": "Κατάργηση κοντέινερ", + "message": "Είστε βέβαιοι ότι θέλετε να καταργήσετε αυτό το κοντέινερ; Τα στοιχεία θα μετακινηθούν στην ίδια θέση στη γονική ενότητα." } } }, diff --git a/packages/translation/src/lang/es.json b/packages/translation/src/lang/es.json index 942684943..095117aad 100644 --- a/packages/translation/src/lang/es.json +++ b/packages/translation/src/lang/es.json @@ -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." } } }, diff --git a/packages/translation/src/lang/et.json b/packages/translation/src/lang/et.json index b0b564b81..8043f42db 100644 --- a/packages/translation/src/lang/et.json +++ b/packages/translation/src/lang/et.json @@ -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." } } }, diff --git a/packages/translation/src/lang/fi.json b/packages/translation/src/lang/fi.json index c0074a1c7..68bc73c1f 100644 --- a/packages/translation/src/lang/fi.json +++ b/packages/translation/src/lang/fi.json @@ -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." } } }, diff --git a/packages/translation/src/lang/fr.json b/packages/translation/src/lang/fr.json index cc96389c3..8cc322bab 100644 --- a/packages/translation/src/lang/fr.json +++ b/packages/translation/src/lang/fr.json @@ -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." } } }, diff --git a/packages/translation/src/lang/he.json b/packages/translation/src/lang/he.json index 8c1de9088..9b09a6370 100644 --- a/packages/translation/src/lang/he.json +++ b/packages/translation/src/lang/he.json @@ -1346,8 +1346,8 @@ "section": { "container": { "action": { - "create": "מדור דינמי חדש", - "remove": "הסר קטע דינמי" + "create": "מיכל חדש", + "remove": "הסר מיכל" }, "option": { "title": { @@ -1361,8 +1361,8 @@ } }, "remove": { - "title": "הסר קטע דינמי", - "message": "האם אתה בטוח שברצונך להסיר את הקטע הדינמי הזה? פריטים יועברו לאותו מיקום במדור האב." + "title": "הסר מיכל", + "message": "האם אתה בטוח שברצונך להסיר את המיכל הזה? פריטים יועברו לאותו מיקום במדור האב." } } }, diff --git a/packages/translation/src/lang/hr.json b/packages/translation/src/lang/hr.json index 9375b1a6f..a7adc5a91 100644 --- a/packages/translation/src/lang/hr.json +++ b/packages/translation/src/lang/hr.json @@ -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." } } }, diff --git a/packages/translation/src/lang/hu.json b/packages/translation/src/lang/hu.json index 5acc1798d..ed939de0d 100644 --- a/packages/translation/src/lang/hu.json +++ b/packages/translation/src/lang/hu.json @@ -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." } } }, diff --git a/packages/translation/src/lang/it.json b/packages/translation/src/lang/it.json index 57a6fe3b6..312404a45 100644 --- a/packages/translation/src/lang/it.json +++ b/packages/translation/src/lang/it.json @@ -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." } } }, diff --git a/packages/translation/src/lang/ja.json b/packages/translation/src/lang/ja.json index 2c033a314..42c3cc4a4 100644 --- a/packages/translation/src/lang/ja.json +++ b/packages/translation/src/lang/ja.json @@ -1346,8 +1346,8 @@ "section": { "container": { "action": { - "create": "新しいダイナミックセクション", - "remove": "ダイナミックセクションを削除する" + "create": "新しいコンテナー", + "remove": "コンテナーを削除する" }, "option": { "title": { @@ -1361,8 +1361,8 @@ } }, "remove": { - "title": "ダイナミックセクションの削除", - "message": "この動的セクションを削除してもよろしいですか?アイテムは親セクションの同じ場所に移動されます。" + "title": "コンテナーの削除", + "message": "このコンテナーを削除してもよろしいですか?アイテムは親セクションの同じ場所に移動されます。" } } }, diff --git a/packages/translation/src/lang/ko.json b/packages/translation/src/lang/ko.json index c5c46fabd..61ee9eea0 100644 --- a/packages/translation/src/lang/ko.json +++ b/packages/translation/src/lang/ko.json @@ -1400,8 +1400,8 @@ "section": { "container": { "action": { - "create": "새 동적 섹션", - "remove": "동적 섹션 제거" + "create": "새 컨테이너", + "remove": "컨테이너 제거" }, "option": { "title": { @@ -1415,8 +1415,8 @@ } }, "remove": { - "title": "동적 섹션 제거", - "message": "이 동적 섹션을 제거하시겠습니까? 항목은 상위 섹션의 동일한 위치로 이동합니다." + "title": "컨테이너 제거", + "message": "이 컨테이너를 제거하시겠습니까? 항목은 상위 섹션의 동일한 위치로 이동합니다." } } }, diff --git a/packages/translation/src/lang/lt.json b/packages/translation/src/lang/lt.json index 9c90968b4..f6a2ab085 100644 --- a/packages/translation/src/lang/lt.json +++ b/packages/translation/src/lang/lt.json @@ -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." } } }, diff --git a/packages/translation/src/lang/lv.json b/packages/translation/src/lang/lv.json index 720de8f7b..04e7592b4 100644 --- a/packages/translation/src/lang/lv.json +++ b/packages/translation/src/lang/lv.json @@ -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ļā." } } }, diff --git a/packages/translation/src/lang/nl.json b/packages/translation/src/lang/nl.json index 9054268b7..5a54df0d4 100644 --- a/packages/translation/src/lang/nl.json +++ b/packages/translation/src/lang/nl.json @@ -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." } } }, diff --git a/packages/translation/src/lang/no.json b/packages/translation/src/lang/no.json index cbeadaab9..a95e469f6 100644 --- a/packages/translation/src/lang/no.json +++ b/packages/translation/src/lang/no.json @@ -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." } } }, diff --git a/packages/translation/src/lang/pl.json b/packages/translation/src/lang/pl.json index 40d5a559e..40aa95058 100644 --- a/packages/translation/src/lang/pl.json +++ b/packages/translation/src/lang/pl.json @@ -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." } } }, diff --git a/packages/translation/src/lang/pt-br.json b/packages/translation/src/lang/pt-br.json index 1a0e67dd5..0f882f2fb 100644 --- a/packages/translation/src/lang/pt-br.json +++ b/packages/translation/src/lang/pt-br.json @@ -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." } } }, diff --git a/packages/translation/src/lang/pt.json b/packages/translation/src/lang/pt.json index 2f960117f..ee0da0ab2 100644 --- a/packages/translation/src/lang/pt.json +++ b/packages/translation/src/lang/pt.json @@ -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." } } }, diff --git a/packages/translation/src/lang/ro.json b/packages/translation/src/lang/ro.json index b323afcb3..545734a74 100644 --- a/packages/translation/src/lang/ro.json +++ b/packages/translation/src/lang/ro.json @@ -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." } } }, diff --git a/packages/translation/src/lang/ru.json b/packages/translation/src/lang/ru.json index 94aafca1b..99b1f3cc1 100644 --- a/packages/translation/src/lang/ru.json +++ b/packages/translation/src/lang/ru.json @@ -1346,8 +1346,8 @@ "section": { "container": { "action": { - "create": "Новый динамический элемент", - "remove": "Удалить динамический элемент" + "create": "Новый контейнер", + "remove": "Удалить контейнер" }, "option": { "title": { @@ -1361,8 +1361,8 @@ } }, "remove": { - "title": "Удалить динамический элемент", - "message": "Вы уверены, что хотите удалить этот динамический элемент? Содержимое будет перемещено в то же место в родительском элементе." + "title": "Удалить контейнер", + "message": "Вы уверены, что хотите удалить этот контейнер? Содержимое будет перемещено в то же место в родительском элементе." } } }, diff --git a/packages/translation/src/lang/sk.json b/packages/translation/src/lang/sk.json index 02cfcfe01..91afc3ea7 100644 --- a/packages/translation/src/lang/sk.json +++ b/packages/translation/src/lang/sk.json @@ -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." } } }, diff --git a/packages/translation/src/lang/sl.json b/packages/translation/src/lang/sl.json index ee6d0e453..a13a267cc 100644 --- a/packages/translation/src/lang/sl.json +++ b/packages/translation/src/lang/sl.json @@ -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." } } }, diff --git a/packages/translation/src/lang/sv.json b/packages/translation/src/lang/sv.json index 6e22566bb..7cd7aaf8c 100644 --- a/packages/translation/src/lang/sv.json +++ b/packages/translation/src/lang/sv.json @@ -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." } } }, diff --git a/packages/translation/src/lang/tr.json b/packages/translation/src/lang/tr.json index 559cb87cb..f63720d8f 100644 --- a/packages/translation/src/lang/tr.json +++ b/packages/translation/src/lang/tr.json @@ -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." } } }, diff --git a/packages/translation/src/lang/uk.json b/packages/translation/src/lang/uk.json index 274fd5d0a..4a05a17f2 100644 --- a/packages/translation/src/lang/uk.json +++ b/packages/translation/src/lang/uk.json @@ -1356,8 +1356,8 @@ "section": { "container": { "action": { - "create": "Нова динамічна секція", - "remove": "Видалити динамічну секцію" + "create": "Новий контейнер", + "remove": "Видалити контейнер" }, "option": { "title": { @@ -1371,8 +1371,8 @@ } }, "remove": { - "title": "Видалити динамічний розділ", - "message": "Ви впевнені, що хочете видалити цей динамічний розділ? Елементи будуть переміщені в те саме місце в батьківському розділі." + "title": "Видалити контейнер", + "message": "Ви впевнені, що хочете видалити цей контейнер? Елементи будуть переміщені в те саме місце в батьківському розділі." } } }, diff --git a/packages/translation/src/lang/vi.json b/packages/translation/src/lang/vi.json index b65c7297a..6aaec1f3b 100644 --- a/packages/translation/src/lang/vi.json +++ b/packages/translation/src/lang/vi.json @@ -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." } } }, diff --git a/packages/translation/src/lang/zh.json b/packages/translation/src/lang/zh.json index e80d11174..0b62df8c0 100644 --- a/packages/translation/src/lang/zh.json +++ b/packages/translation/src/lang/zh.json @@ -1346,8 +1346,8 @@ "section": { "container": { "action": { - "create": "創建動態區段", - "remove": "移除動態區段" + "create": "新增容器", + "remove": "移除容器" }, "option": { "title": { @@ -1361,8 +1361,8 @@ } }, "remove": { - "title": "移除動態區段", - "message": "確認要移除此動態區段嗎?項目將移動到父區段的相同位置" + "title": "移除容器", + "message": "確認要移除此容器嗎?項目將移動到父區段的相同位置" } } },