feat: rebuild board grid with dnd-kit
This commit is contained in:
1 parent
5bde4d3cc5
commit
1f5311e93b
107 files changed
+5628
-3209
No files matched your search
@@ -321,39 +321,34 @@ As with every change, don't forget to save your changes using the exit edit mode
|
||||
|
||||

|
||||
|
||||
## Organizing items with sections
|
||||
## Organizing items with Containers
|
||||
|
||||
Sections are resizable containers for apps, widgets, and other sections.
|
||||
The same section works on the main canvas and inside a fixed sidebar.
|
||||
Each section can configure:
|
||||
Containers are resizable areas for apps, widgets, and nested Containers.
|
||||
The same Container works on the main canvas, inside a fixed rail, or inside another Container.
|
||||
Each Container can configure:
|
||||
|
||||
- Show or hide its label.
|
||||
- Its label and whether that label is visible.
|
||||
- Allow or disable collapsing.
|
||||
- Show or hide **Open all in new tabs**.
|
||||
- Change its border color and custom CSS classes.
|
||||
|
||||

|
||||
Open the add-content menu and choose **New Container**.
|
||||
In edit mode, move and resize it like any other item, then drag items into or out of it.
|
||||
Nested content stays within the Container's bounds.
|
||||
|
||||
After adding a section, drag and resize it like any other tile, then drag items into it:
|
||||
### Collapsing and opening apps
|
||||
|
||||

|
||||
Users can collapse an eligible Container like an accordion in view mode without changing its saved expanded size.
|
||||
Expanding it restores its bounds and makes room for its contents.
|
||||
If **Open all in new tabs** is enabled, the Container menu can open every app link it contains.
|
||||
|
||||
### Labeled and collapsible sections
|
||||
### Fixed rails
|
||||
|
||||
Open the add-content menu and choose **New section**.
|
||||
Use its settings menu to add a label and enable the behavior you want.
|
||||
In edit mode, move items into or out of the section directly.
|
||||
In view mode, each user can collapse eligible sections without changing the saved board layout.
|
||||
If **Open all in new tabs** is enabled, the section menu can open every app link it contains.
|
||||
|
||||
### Fixed sidebars
|
||||
|
||||
Open the board's **Layout** settings to enable the left or right sidebar for each responsive layout.
|
||||
Open the board's **Layout** settings to enable the left or right rail for each responsive layout.
|
||||
Choose one, two, or three columns with the slider and use the preview to check the remaining canvas width.
|
||||
The sidebar reserves those columns from the dashboard, so Homarr moves canvas items into the next available positions.
|
||||
Sidebar content stays visible while the page scrolls and remains editable.
|
||||
|
||||
Existing categories become sections automatically, and category rails become fixed sidebars.
|
||||
The rail reserves those columns from the dashboard, so Homarr moves canvas items into the next available positions.
|
||||
Rail content stays visible while the page scrolls and remains editable.
|
||||
Each responsive layout keeps its own rail widths, item positions, and item sizes.
|
||||
|
||||
## Guided tours
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# Glossary
|
||||
|
||||
In the glossary, you'll learn the different components and their names of Homarr.
|
||||
|
||||
| Term | Definition |
|
||||
|-----------------|----------------------------------------------------------------------------------------------------------------|
|
||||
| Apps | An piece of software that you or a third party hosts (eg. Google) |
|
||||
| Integration | A connection to a service outside of Homarr |
|
||||
| Dynamic section | Resizeable area where items on your board can be placed and grouped |
|
||||
| Widget | A tile that uses your integrations to display data or enables you to interact with with them |
|
||||
| Edit mode | Temporary state that you can enter where you can drag and drop, edit or customize your board |
|
||||
| Board | Customizable page which can be edited using the edit mode. This is where apps, sections and widgets are placed |
|
||||
| Tasks | Workloads that are performed in the background - even if there is no user present |
|
||||
| Term | Definition |
|
||||
| ----------- | --------------------------------------------------------------------------------------- |
|
||||
| Apps | A piece of software that you or a third party hosts (for example, Google) |
|
||||
| Integration | A connection to a service outside of Homarr |
|
||||
| Container | A resizable area that can hold apps, widgets, and nested Containers |
|
||||
| Widget | A tile that uses your integrations to display data or enables you to interact with them |
|
||||
| Edit mode | A temporary state where you can drag, resize, edit, or customize your board |
|
||||
| Board | A customizable page where apps, widgets, and Containers are arranged |
|
||||
| Tasks | Workloads that are performed in the background, even if no user is present |
|
||||
@@ -14,8 +14,12 @@
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "catalog:",
|
||||
"@dnd-kit/collision": "catalog:",
|
||||
"@dnd-kit/core": "catalog:",
|
||||
"@dnd-kit/dom": "catalog:",
|
||||
"@dnd-kit/modifiers": "catalog:",
|
||||
"@dnd-kit/react": "catalog:",
|
||||
"@dnd-kit/sortable": "catalog:",
|
||||
"@dnd-kit/utilities": "catalog:",
|
||||
"@gfazioli/mantine-onboarding-tour": "catalog:",
|
||||
@@ -82,7 +86,6 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"flag-icons": "catalog:",
|
||||
"glob": "catalog:",
|
||||
"gridstack": "catalog:",
|
||||
"isomorphic-dompurify": "catalog:",
|
||||
"jotai": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { useContainerActions } from "~/components/board/sections/container/container-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
@@ -23,7 +23,7 @@ const AddMenu = () => {
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const { addContainer } = useContainerActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
|
||||
@@ -68,8 +68,8 @@ const AddMenu = () => {
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addDynamicSection}>
|
||||
{t("section.dynamic.action.create")}
|
||||
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
|
||||
{t("section.container.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@@ -7,10 +7,9 @@ export type Item = Board["items"][number];
|
||||
export type ItemLayout = Item["layouts"][number];
|
||||
export type SectionItem = Omit<Item, "layouts"> & ItemLayout & { type: "item" };
|
||||
|
||||
export type CategorySection = Extract<Section, { kind: "category" }>;
|
||||
export type EmptySection = Extract<Section, { kind: "empty" }>;
|
||||
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
|
||||
export type DynamicSectionLayout = DynamicSection["layouts"][number];
|
||||
export type DynamicSectionItem = Omit<DynamicSection, "layouts"> & DynamicSectionLayout & { type: "section" };
|
||||
export type ContainerSection = Extract<Section, { kind: "container" }>;
|
||||
export type ContainerSectionLayout = ContainerSection["layouts"][number];
|
||||
export type ContainerSectionItem = Omit<ContainerSection, "layouts"> & ContainerSectionLayout & { type: "section" };
|
||||
|
||||
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
|
||||
@@ -76,11 +76,8 @@ const getEmptySectionPosition = (
|
||||
if (!boardLayout) return;
|
||||
|
||||
const sectionElements = getSectionElements(board, { sectionId: section.id, layoutId: layout.layoutId });
|
||||
if (section.kind !== "dynamic") {
|
||||
const columnCount =
|
||||
section.kind === "empty"
|
||||
? getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset))
|
||||
: boardLayout.columnCount;
|
||||
if (section.kind === "empty") {
|
||||
const columnCount = getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset));
|
||||
|
||||
const size = {
|
||||
width: layout.width,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const getSectionElements = (board: Board, { sectionId, layoutId }: { sectionId: string; layoutId: string }) => {
|
||||
const dynamicSectionsOfFirstSection = board.sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
const containersOfSection = board.sections
|
||||
.filter((section) => section.kind === "container")
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
.map(({ layouts, ...section }) => ({ ...section, ...layouts.find((layout) => layout.layoutId === layoutId)! }))
|
||||
.filter((section) => section.parentSectionId === sectionId);
|
||||
@@ -14,5 +14,5 @@ export const getSectionElements = (board: Board, { sectionId, layoutId }: { sect
|
||||
}))
|
||||
.filter((item) => item.sectionId === sectionId);
|
||||
|
||||
return [...items, ...dynamicSectionsOfFirstSection];
|
||||
return [...items, ...containersOfSection];
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import * as boardContext from "@homarr/boards/context";
|
||||
import { createItemCallback } from "../create-item";
|
||||
import * as emptyPositionModule from "../empty-position";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
|
||||
import { ContainerSectionMockBuilder } from "./mocks/container-section-mock";
|
||||
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("item actions create-item", () => {
|
||||
);
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
|
||||
});
|
||||
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
|
||||
test("should correctly pass containers and items to getFirstEmptyPosition", () => {
|
||||
// Arrange
|
||||
const itemKind = "clock";
|
||||
const emptyPosition = { xOffset: 5, yOffset: 5 };
|
||||
@@ -67,21 +67,21 @@ describe("item actions create-item", () => {
|
||||
const itemAndSectionPosition = { height: 2, width: 3, yOffset: 2, xOffset: 1 };
|
||||
|
||||
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
|
||||
const dynamicSectionInFirstSection = new DynamicSectionMockBuilder({ id: "4" })
|
||||
const containerInFirstSection = new ContainerSectionMockBuilder({ id: "4" })
|
||||
.addLayout({ ...itemAndSectionPosition, layoutId, parentSectionId: firstSectionId })
|
||||
.build();
|
||||
const itemInFirstSection = new ItemMockBuilder({ id: "12" })
|
||||
.addLayout({ ...itemAndSectionPosition, layoutId, sectionId: firstSectionId })
|
||||
.build();
|
||||
const otherDynamicSection = new DynamicSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
|
||||
const otherContainer = new ContainerSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
|
||||
const otherItem = new ItemMockBuilder({ id: "13" }).addLayout({ layoutId }).build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout(layout)
|
||||
.addEmptySection({ id: "1", yOffset: 2 })
|
||||
.addEmptySection({ id: firstSectionId, yOffset: 0 })
|
||||
.addEmptySection({ id: "3", yOffset: 1 })
|
||||
.addSection(dynamicSectionInFirstSection)
|
||||
.addSection(otherDynamicSection)
|
||||
.addSection(containerInFirstSection)
|
||||
.addSection(otherContainer)
|
||||
.addItem(itemInFirstSection)
|
||||
.addItem(otherItem)
|
||||
.build();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getMoveTargets } from "../../item-move-modal";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
|
||||
import { ContainerSectionMockBuilder } from "./mocks/container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "./mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
|
||||
@@ -20,8 +20,8 @@ describe("item move targets", () => {
|
||||
new EmptySectionMockBuilder({ id: "canvas", xOffset: 0 }).build(),
|
||||
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
|
||||
new EmptySectionMockBuilder({ id: "right-gutter", xOffset: 1 }).build(),
|
||||
new DynamicSectionMockBuilder({
|
||||
id: "dynamic",
|
||||
new ContainerSectionMockBuilder({
|
||||
id: "container",
|
||||
options: {
|
||||
title: "",
|
||||
customCssClasses: [],
|
||||
@@ -53,7 +53,7 @@ describe("item move targets", () => {
|
||||
},
|
||||
{
|
||||
canvas: "Dashboard canvas",
|
||||
dynamicSection: "New section",
|
||||
container: "New section",
|
||||
leftRail: "Left dashboard rail",
|
||||
rightRail: "Right dashboard rail",
|
||||
numbered: (name, index) => `${name}, destination ${index}`,
|
||||
@@ -78,7 +78,7 @@ describe("item move targets", () => {
|
||||
layout.columnCount = 5;
|
||||
layout.leftGutterColumnCount = 2;
|
||||
|
||||
const section = new DynamicSectionMockBuilder({ id: "source" })
|
||||
const section = new ContainerSectionMockBuilder({ id: "source" })
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
parentSectionId: "canvas",
|
||||
@@ -110,7 +110,7 @@ describe("item move targets", () => {
|
||||
{ ...sourceLayout, id: section.id, type: "section" },
|
||||
{
|
||||
canvas: "Canvas",
|
||||
dynamicSection: "Section",
|
||||
container: "Section",
|
||||
leftRail: "Left",
|
||||
rightRail: "Right",
|
||||
numbered: (name, index) => `${name} ${index}`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import type { Board, DynamicSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||
import { DynamicSectionMockBuilder } from "./dynamic-section-mock";
|
||||
import type { Board, ContainerSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||
import { ContainerSectionMockBuilder } from "./container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "./empty-section-mock";
|
||||
import { ItemMockBuilder } from "./item-mock";
|
||||
import { LayoutMockBuilder } from "./layout-mock";
|
||||
@@ -58,8 +58,8 @@ export class BoardMockBuilder {
|
||||
return this.addSection(new EmptySectionMockBuilder(emptySection).build());
|
||||
}
|
||||
|
||||
public addDynamicSection(dynamicSection?: Partial<DynamicSection>): BoardMockBuilder {
|
||||
return this.addSection(new DynamicSectionMockBuilder(dynamicSection).build());
|
||||
public addContainer(container?: Partial<ContainerSection>): BoardMockBuilder {
|
||||
return this.addSection(new ContainerSectionMockBuilder(container).build());
|
||||
}
|
||||
|
||||
public addSection(section: Section): BoardMockBuilder {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import type { ContainerSection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
type ContainerSectionMock = Omit<Partial<ContainerSection>, "options"> & {
|
||||
options?: Partial<ContainerSection["options"]>;
|
||||
};
|
||||
|
||||
export class ContainerSectionMockBuilder {
|
||||
private readonly section: ContainerSection;
|
||||
|
||||
constructor(section?: ContainerSectionMock) {
|
||||
this.section = {
|
||||
id: section?.id ?? createId(),
|
||||
kind: "container",
|
||||
collapsed: section?.collapsed ?? false,
|
||||
layouts: section?.layouts ?? [],
|
||||
options: containerSectionOptionsSchema.parse(section?.options),
|
||||
};
|
||||
}
|
||||
|
||||
public addLayout(layout?: Partial<ContainerSection["layouts"][0]>): ContainerSectionMockBuilder {
|
||||
this.section.layouts.push({
|
||||
layoutId: layout?.layoutId ?? createId(),
|
||||
parentSectionId: layout?.parentSectionId ?? createId(),
|
||||
xOffset: layout?.xOffset ?? 0,
|
||||
yOffset: layout?.yOffset ?? 0,
|
||||
width: layout?.width ?? 1,
|
||||
height: layout?.height ?? 1,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): ContainerSection {
|
||||
return structuredClone(this.section);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
type DynamicSectionMock = Omit<Partial<DynamicSection>, "options"> & {
|
||||
options?: Partial<DynamicSection["options"]>;
|
||||
};
|
||||
|
||||
export class DynamicSectionMockBuilder {
|
||||
private readonly section: DynamicSection;
|
||||
|
||||
constructor(section?: DynamicSectionMock) {
|
||||
this.section = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
layouts: [],
|
||||
...section,
|
||||
collapsed: section?.collapsed ?? false,
|
||||
options: dynamicSectionOptionsSchema.parse(section?.options),
|
||||
} satisfies DynamicSection;
|
||||
}
|
||||
|
||||
public addLayout(layout?: Partial<DynamicSection["layouts"][0]>): DynamicSectionMockBuilder {
|
||||
this.section.layouts.push({
|
||||
layoutId: "1",
|
||||
height: 1,
|
||||
width: 1,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
parentSectionId: "0",
|
||||
...layout,
|
||||
} satisfies DynamicSection["layouts"][0]);
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): DynamicSection {
|
||||
return this.section;
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,8 @@
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover)
|
||||
.wrapper,
|
||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within)
|
||||
.wrapper {
|
||||
:global([data-type="item"]:not([data-kind="app"]):hover) .wrapper,
|
||||
:global([data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) =>
|
||||
: [];
|
||||
}),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "dynamic") return [];
|
||||
if (section.kind !== "container") return [];
|
||||
const sectionLayout = section.layouts.find(
|
||||
(candidate) => candidate.layoutId === layoutId && candidate.parentSectionId === root.id,
|
||||
);
|
||||
@@ -75,7 +75,7 @@ export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) =>
|
||||
board.sections
|
||||
.filter(
|
||||
(section) =>
|
||||
section.kind === "dynamic" &&
|
||||
section.kind === "container" &&
|
||||
section.collapsed &&
|
||||
section.options.collapsible &&
|
||||
placements.some((placement) => placement.id === section.id),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
@@ -10,6 +10,10 @@ import classes from "./scaled-board-canvas.module.css";
|
||||
|
||||
export { MIN_ACCESSIBLE_CANVAS_SCALE };
|
||||
|
||||
const BoardCanvasScaleContext = createContext(1);
|
||||
|
||||
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
|
||||
|
||||
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
|
||||
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
|
||||
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
|
||||
@@ -84,7 +88,7 @@ export const ScaledBoardCanvas = ({
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock";
|
||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, getRootSectionForLane } from "../index";
|
||||
@@ -51,13 +51,13 @@ describe("board lanes", () => {
|
||||
const layoutId = "layout";
|
||||
const mainSectionId = "main";
|
||||
const leftSectionId = "left";
|
||||
const dynamicSectionId = "dynamic";
|
||||
const containerSectionId = "container";
|
||||
const board = new BoardMockBuilder()
|
||||
.addSection(new EmptySectionMockBuilder({ id: mainSectionId }).build())
|
||||
.addSection(new EmptySectionMockBuilder({ id: leftSectionId, xOffset: -1 }).build())
|
||||
.addSection(
|
||||
new DynamicSectionMockBuilder({
|
||||
id: dynamicSectionId,
|
||||
new ContainerSectionMockBuilder({
|
||||
id: containerSectionId,
|
||||
collapsed: true,
|
||||
options: { collapsible: true },
|
||||
})
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets/definition";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const filterByItemKind = async <TKind extends WidgetKind>(
|
||||
items: Item[],
|
||||
settings: SettingsContextProps,
|
||||
kind: TKind,
|
||||
) => {
|
||||
const definition = await loadWidgetDefinition(kind);
|
||||
|
||||
return items
|
||||
.filter((item) => item.kind === kind)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
options: reduceWidgetOptionsWithDefinition(
|
||||
definition,
|
||||
settings,
|
||||
item.options,
|
||||
) as WidgetComponentProps<TKind>["options"],
|
||||
}));
|
||||
};
|
||||
+27
-20
@@ -1,31 +1,31 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { ActionIcon, Badge, Box, Card } from "@mantine/core";
|
||||
import { IconChevronDown, IconChevronUp, IconExternalLink } from "@tabler/icons-react";
|
||||
import { IconChevronDown, IconChevronUp, IconExternalLink, IconGripVertical } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { SectionGrid } from "./grid/section-grid";
|
||||
import { useSectionCollapse } from "./section-collapse";
|
||||
import { useOpenSectionApps } from "./use-open-section-apps";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
const BoardDynamicSectionMenu = dynamic(
|
||||
() => import("./dynamic/dynamic-menu").then((module) => module.BoardDynamicSectionMenu),
|
||||
const BoardContainerMenu = dynamic(
|
||||
() => import("./container/container-menu").then((module) => module.BoardContainerMenu),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface Props {
|
||||
section: DynamicSectionItem;
|
||||
section: ContainerSectionItem;
|
||||
}
|
||||
|
||||
export const BoardDynamicSection = ({ section }: Props) => {
|
||||
export const BoardContainerSection = ({ section }: Props) => {
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const t = useScopedI18n("section.dynamic");
|
||||
const t = useScopedI18n("section.container");
|
||||
const tAll = useI18n();
|
||||
const options = section.options;
|
||||
const { open: openAllInNewTabs, isLoading: areAppsLoading } = useOpenSectionApps(
|
||||
@@ -37,22 +37,14 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
||||
collapsible: options.collapsible,
|
||||
});
|
||||
const label = options.title || t("action.create");
|
||||
const contentId = `board-section-${section.id}-content`;
|
||||
const labelLeft = !isEditMode && options.collapsible ? 40 : 10;
|
||||
const contentId = `board-container-${section.id}-content`;
|
||||
const labelLeft = isEditMode || options.collapsible ? 40 : 10;
|
||||
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="grid-stack-item-content"
|
||||
data-grid-item-content
|
||||
w="100%"
|
||||
h="100%"
|
||||
style={{
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
<Box className="board-grid-item-content" data-grid-item-content w="100%" h="100%" style={{ overflow: "visible" }}>
|
||||
<Card
|
||||
className={combineClasses(classes.itemCard, section.options.customCssClasses.join(" "))}
|
||||
className={combineClasses(classes.itemCard, options.customCssClasses.join(" "))}
|
||||
w="100%"
|
||||
h="100%"
|
||||
styles={{
|
||||
@@ -65,6 +57,21 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
||||
radius={board.itemRadius}
|
||||
p={0}
|
||||
>
|
||||
{isEditMode && (
|
||||
<ActionIcon
|
||||
component="span"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
left={4}
|
||||
style={{ zIndex: 10, cursor: "grab", touchAction: "none" }}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
data-grid-container-drag-handle
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IconGripVertical size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{options.showLabel && options.title && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
@@ -123,7 +130,7 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
{isEditMode && <BoardDynamicSectionMenu section={section} />}
|
||||
{isEditMode && <BoardContainerMenu section={section} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+10
-12
@@ -1,14 +1,14 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import type { Board, DynamicSection, DynamicSectionLayout, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import type { Board, ContainerSection, ContainerSectionLayout, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
|
||||
import { getSectionElements } from "~/components/board/items/actions/section-elements";
|
||||
|
||||
export const addDynamicSectionCallback = () => (board: Board) => {
|
||||
export const addContainerCallback = () => (board: Board) => {
|
||||
const firstSection = board.sections
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
@@ -18,22 +18,22 @@ export const addDynamicSectionCallback = () => (board: Board) => {
|
||||
|
||||
if (!firstSection) return board;
|
||||
|
||||
const sectionLayouts = createDynamicSectionLayouts(board, firstSection);
|
||||
const sectionLayouts = createContainerLayouts(board, firstSection);
|
||||
const newSection = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
kind: "container",
|
||||
collapsed: false,
|
||||
options: dynamicSectionOptionsSchema.parse(undefined),
|
||||
options: containerSectionOptionsSchema.parse(undefined),
|
||||
layouts: sectionLayouts,
|
||||
} satisfies DynamicSection;
|
||||
} satisfies ContainerSection;
|
||||
|
||||
return {
|
||||
...board,
|
||||
sections: board.sections.concat(newSection as unknown as DynamicSection),
|
||||
sections: board.sections.concat(newSection),
|
||||
};
|
||||
};
|
||||
|
||||
const createDynamicSectionLayouts = (board: Board, currentSection: EmptySection): DynamicSectionLayout[] => {
|
||||
const createContainerLayouts = (board: Board, currentSection: EmptySection): ContainerSectionLayout[] => {
|
||||
const layouts = getBoardLayouts(board);
|
||||
|
||||
return layouts.map((layoutId) => {
|
||||
@@ -49,9 +49,7 @@ const createDynamicSectionLayouts = (board: Board, currentSection: EmptySection)
|
||||
? getFirstEmptyPosition(elements, columnCount, undefined, size)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) {
|
||||
throw new Error("Your board is full");
|
||||
}
|
||||
if (!emptyPosition) throw new Error("Your board is full");
|
||||
|
||||
return {
|
||||
...size,
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import type { ContainerSection } from "~/app/[locale]/boards/_types";
|
||||
import * as emptyPositionModule from "~/components/board/items/actions/empty-position";
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { addContainerCallback } from "./add-container";
|
||||
import { removeContainerCallback } from "./remove-container";
|
||||
|
||||
describe("container actions", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("creates a container in the first main root for every responsive layout", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "root", yOffset: 0 }).build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 2;
|
||||
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layout.id]);
|
||||
vi.spyOn(emptyPositionModule, "getFirstEmptyPosition").mockReturnValue({ xOffset: 0, yOffset: 4 });
|
||||
|
||||
const result = addContainerCallback()(board);
|
||||
const created = result.sections.find((section) => section.kind === "container");
|
||||
|
||||
expect(created).toMatchObject({
|
||||
kind: "container",
|
||||
collapsed: false,
|
||||
options: {
|
||||
showLabel: true,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
},
|
||||
layouts: [
|
||||
{
|
||||
layoutId: "layout",
|
||||
parentSectionId: "root",
|
||||
xOffset: 0,
|
||||
yOffset: 4,
|
||||
width: 2,
|
||||
height: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("removes a container and flattens its direct children into the parent", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "root", xOffset: 3, yOffset: 4, width: 6, height: 5 })
|
||||
.build();
|
||||
const child = new ContainerSectionMockBuilder({ id: "child" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2, width: 2, height: 2 })
|
||||
.build();
|
||||
const nestedItem = new ItemMockBuilder({ id: "nested-item" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1, width: 1, height: 1 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addSection(child).addItem(nestedItem).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
const flattenedChild = result.sections.find(
|
||||
(section): section is ContainerSection => section.kind === "container" && section.id === child.id,
|
||||
);
|
||||
|
||||
expect(result.sections.some((section) => section.id === removed.id)).toBe(false);
|
||||
expect(flattenedChild?.layouts[0]).toMatchObject({
|
||||
parentSectionId: "root",
|
||||
xOffset: 4,
|
||||
yOffset: 6,
|
||||
});
|
||||
expect(result.items.find((item) => item.id === nestedItem.id)?.layouts[0]).toMatchObject({
|
||||
sectionId: "root",
|
||||
xOffset: 5,
|
||||
yOffset: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes and resolves collisions while flattening malformed child geometry", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 2 })
|
||||
.build();
|
||||
const existing = new ItemMockBuilder({ id: "existing" })
|
||||
.addLayout({ layoutId: "layout", sectionId: "root", xOffset: 0, yOffset: 0, width: 2, height: 1 })
|
||||
.build();
|
||||
const childA = new ItemMockBuilder({ id: "a" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 1, yOffset: 0, width: 2, height: 1 })
|
||||
.build();
|
||||
const childB = new ItemMockBuilder({ id: "b" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 0, yOffset: 0, width: 2, height: 1 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addEmptySection({ id: "root" })
|
||||
.addSection(removed)
|
||||
.addItems([existing, childA, childB])
|
||||
.build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 4;
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
const layouts = Object.fromEntries(
|
||||
result.items.map((item) => {
|
||||
const itemLayout = item.layouts[0];
|
||||
if (!itemLayout) throw new Error("Expected an item layout");
|
||||
return [item.id, itemLayout];
|
||||
}),
|
||||
);
|
||||
|
||||
expect(layouts.existing).toMatchObject({ xOffset: 0, yOffset: 0, width: 2, height: 1 });
|
||||
expect(layouts.a).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 1 });
|
||||
expect(layouts.b).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 1, width: 2, height: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board, ContainerSection } from "~/app/[locale]/boards/_types";
|
||||
import { resolvePinnedGridCollisions } from "~/components/board/sections/grid/dnd";
|
||||
import type { SectionGridPlacement } from "~/components/board/sections/grid/use-grid-layout-actions";
|
||||
|
||||
export interface RemoveContainerInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const removeContainerCallback =
|
||||
({ id }: RemoveContainerInput) =>
|
||||
(board: Board): Board => {
|
||||
const sectionToRemove = board.sections.find(
|
||||
(section): section is ContainerSection => section.id === id && section.kind === "container",
|
||||
);
|
||||
if (!sectionToRemove) return board;
|
||||
|
||||
const flattened = {
|
||||
...board,
|
||||
sections: board.sections
|
||||
.filter((section) => section.id !== id)
|
||||
.map((section) => {
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
return {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.parentSectionId !== sectionToRemove.id) return layout;
|
||||
|
||||
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||
);
|
||||
if (!removedSectionLayout) throw new Error("Layout not found");
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
parentSectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
items: board.items.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => {
|
||||
if (layout.sectionId !== sectionToRemove.id) return layout;
|
||||
|
||||
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||
);
|
||||
if (!removedSectionLayout) throw new Error("Layout not found");
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
sectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
return resolveFlattenedLayouts(flattened, sectionToRemove, board);
|
||||
};
|
||||
|
||||
const resolveFlattenedLayouts = (board: Board, removed: ContainerSection, original: Board): Board => {
|
||||
const resolvedByLayoutAndId = new Map<string, SectionGridPlacement>();
|
||||
|
||||
for (const removedLayout of removed.layouts) {
|
||||
const movedIds = [
|
||||
...original.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === removedLayout.layoutId);
|
||||
return layout?.sectionId === removed.id ? [item.id] : [];
|
||||
}),
|
||||
...original.sections.flatMap((section) => {
|
||||
if (section.kind !== "container") return [];
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === removedLayout.layoutId);
|
||||
return layout?.parentSectionId === removed.id ? [section.id] : [];
|
||||
}),
|
||||
];
|
||||
const pinnedId = movedIds.toSorted()[0];
|
||||
if (!pinnedId) continue;
|
||||
|
||||
const columnCount = getParentColumnCount(board, removedLayout.layoutId, removedLayout.parentSectionId);
|
||||
if (!columnCount) continue;
|
||||
const placements = getSectionPlacements(board, removedLayout.layoutId, removedLayout.parentSectionId);
|
||||
const pinned = placements.find((placement) => placement.id === pinnedId);
|
||||
if (!pinned) continue;
|
||||
|
||||
for (const placement of resolvePinnedGridCollisions(placements, pinned, columnCount)) {
|
||||
resolvedByLayoutAndId.set(`${removedLayout.layoutId}:${placement.id}`, placement);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedByLayoutAndId.size === 0) return board;
|
||||
return {
|
||||
...board,
|
||||
items: board.items.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => {
|
||||
const placement = resolvedByLayoutAndId.get(`${layout.layoutId}:${item.id}`);
|
||||
return placement?.type === "item"
|
||||
? {
|
||||
...layout,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
}
|
||||
: layout;
|
||||
}),
|
||||
})),
|
||||
sections: board.sections.map((section) =>
|
||||
section.kind !== "container"
|
||||
? section
|
||||
: {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
const placement = resolvedByLayoutAndId.get(`${layout.layoutId}:${section.id}`);
|
||||
return placement?.type === "section"
|
||||
? {
|
||||
...layout,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
}
|
||||
: layout;
|
||||
}),
|
||||
},
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const getSectionPlacements = (board: Board, layoutId: string, sectionId: string): SectionGridPlacement[] => [
|
||||
...board.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout?.sectionId === sectionId
|
||||
? [
|
||||
{
|
||||
id: item.id,
|
||||
type: "item" as const,
|
||||
x: layout.xOffset,
|
||||
y: layout.yOffset,
|
||||
w: layout.width,
|
||||
h: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "container") return [];
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout?.parentSectionId === sectionId
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
type: "section" as const,
|
||||
x: layout.xOffset,
|
||||
y: layout.yOffset,
|
||||
w: layout.width,
|
||||
h: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
];
|
||||
|
||||
const getParentColumnCount = (board: Board, layoutId: string, sectionId: string) => {
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) return null;
|
||||
if (section.kind === "container") {
|
||||
return section.layouts.find((layout) => layout.layoutId === layoutId)?.width ?? null;
|
||||
}
|
||||
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
|
||||
return layout ? getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset)) : null;
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback } from "react";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { addContainerCallback } from "./actions/add-container";
|
||||
import type { RemoveContainerInput } from "./actions/remove-container";
|
||||
import { removeContainerCallback } from "./actions/remove-container";
|
||||
|
||||
interface UpdateContainerOptions {
|
||||
containerId: string;
|
||||
newOptions: z.infer<typeof containerSectionOptionsSchema>;
|
||||
}
|
||||
|
||||
export const useContainerActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const addContainer = useCallback(() => {
|
||||
updateBoard(addContainerCallback());
|
||||
}, [updateBoard]);
|
||||
|
||||
const updateContainer = useCallback(
|
||||
({ containerId, newOptions }: UpdateContainerOptions) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((item) =>
|
||||
item.id !== containerId || item.kind !== "container" ? item : { ...item, options: newOptions },
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeContainer = useCallback(
|
||||
(input: RemoveContainerInput) => {
|
||||
updateBoard(removeContainerCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return { addContainer, updateContainer, removeContainer };
|
||||
};
|
||||
+8
-9
@@ -7,18 +7,17 @@ import { useZodForm } from "@homarr/form";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { TextMultiSelect } from "@homarr/ui";
|
||||
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
interface ModalProps {
|
||||
value: z.infer<typeof dynamicSectionOptionsSchema>;
|
||||
onSuccessfulEdit: (value: z.infer<typeof dynamicSectionOptionsSchema>) => void;
|
||||
value: z.infer<typeof containerSectionOptionsSchema>;
|
||||
onSuccessfulEdit: (value: z.infer<typeof containerSectionOptionsSchema>) => void;
|
||||
}
|
||||
|
||||
export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, innerProps }) => {
|
||||
export const ContainerEditModal = createModal<ModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const form = useZodForm(dynamicSectionOptionsSchema.unwrap(), {
|
||||
const form = useZodForm(containerSectionOptionsSchema.unwrap(), {
|
||||
mode: "controlled",
|
||||
initialValues: { ...innerProps.value },
|
||||
});
|
||||
@@ -31,7 +30,7 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label={t("section.dynamic.option.title.label")} data-autofocus {...form.getInputProps("title")} />
|
||||
<TextInput label={t("section.container.option.title.label")} data-autofocus {...form.getInputProps("title")} />
|
||||
<Switch
|
||||
label={t("section.option.showLabel.label")}
|
||||
description={t("section.option.showLabel.description")}
|
||||
@@ -48,11 +47,11 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
|
||||
{...form.getInputProps("showOpenAll", { type: "checkbox" })}
|
||||
/>
|
||||
<TextMultiSelect
|
||||
label={t("section.dynamic.option.customCssClasses.label")}
|
||||
label={t("section.container.option.customCssClasses.label")}
|
||||
{...form.getInputProps("customCssClasses")}
|
||||
/>
|
||||
<ColorInput
|
||||
label={t("section.dynamic.option.borderColor.label")}
|
||||
label={t("section.container.option.borderColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
rightSection={
|
||||
+17
-29
@@ -5,46 +5,39 @@ import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useOpenItemMoveModal } from "../../items/item-move-modal";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { useDynamicSectionActions } from "./dynamic-actions";
|
||||
import { DynamicSectionEditModal } from "./dynamic-edit-modal";
|
||||
import { useContainerActions } from "./container-actions";
|
||||
import { ContainerEditModal } from "./container-edit-modal";
|
||||
|
||||
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionItem }) => {
|
||||
export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem }) => {
|
||||
const t = useI18n();
|
||||
const tDynamic = useScopedI18n("section.dynamic");
|
||||
const tContainer = useScopedI18n("section.container");
|
||||
const tItem = useScopedI18n("item");
|
||||
const { openModal } = useModalAction(DynamicSectionEditModal);
|
||||
const { openModal } = useModalAction(ContainerEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { updateDynamicSection, removeDynamicSection } = useDynamicSectionActions();
|
||||
const { updateContainer, removeContainer } = useContainerActions();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { section: parentSection } = useSectionContext();
|
||||
const label = section.options.title || tDynamic("action.create");
|
||||
const menuRightOffset = parentSection.kind === "dynamic" ? 44 : 4;
|
||||
const label = section.options.title || tContainer("action.create");
|
||||
const menuRightOffset = parentSection.kind === "container" ? 44 : 4;
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
const openEditModal = () => {
|
||||
openModal({
|
||||
value: section.options,
|
||||
onSuccessfulEdit: (options) => {
|
||||
updateDynamicSection({
|
||||
itemId: section.id,
|
||||
newOptions: options,
|
||||
});
|
||||
},
|
||||
onSuccessfulEdit: (options) => updateContainer({ containerId: section.id, newOptions: options }),
|
||||
});
|
||||
};
|
||||
|
||||
const openRemoveModal = () => {
|
||||
openConfirmModal({
|
||||
title: tDynamic("remove.title"),
|
||||
children: tDynamic("remove.message"),
|
||||
onConfirm: () => {
|
||||
removeDynamicSection({ id: section.id });
|
||||
},
|
||||
title: tContainer("remove.title"),
|
||||
children: tContainer("remove.message"),
|
||||
onConfirm: () => removeContainer({ id: section.id }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -53,14 +46,14 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius={"xl"}
|
||||
radius="xl"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
right={menuRightOffset}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
<IconLayoutKanban size={"1rem"} />
|
||||
<IconLayoutKanban size="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
@@ -70,19 +63,14 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconArrowsMove size={16} />}
|
||||
onClick={() =>
|
||||
openMoveModal({
|
||||
entry: section,
|
||||
sourceSectionId: section.parentSectionId,
|
||||
})
|
||||
}
|
||||
onClick={() => openMoveModal({ entry: section, sourceSectionId: section.parentSectionId })}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
{tDynamic("action.remove")}
|
||||
{tContainer("action.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { Board, DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface RemoveDynamicSectionInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const removeDynamicSectionCallback =
|
||||
({ id }: RemoveDynamicSectionInput) =>
|
||||
(board: Board): Board => {
|
||||
const sectionToRemove = board.sections.find(
|
||||
(section): section is DynamicSection => section.id === id && section.kind === "dynamic",
|
||||
);
|
||||
if (!sectionToRemove) return board;
|
||||
|
||||
return {
|
||||
...board,
|
||||
sections: board.sections
|
||||
.filter((section) => section.id !== id)
|
||||
.map((section) => {
|
||||
if (section.kind !== "dynamic") return section;
|
||||
|
||||
// Change xOffset and yOffset of sections that were below the removed section and set parentSectionId to the parent of the removed section
|
||||
return {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.parentSectionId !== sectionToRemove.id) return layout;
|
||||
|
||||
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||
);
|
||||
if (!removedSectionLayout) throw new Error("Layout not found");
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
parentSectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
// Move all items in dynamic section to parent of the removed section
|
||||
items: board.items.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => {
|
||||
if (layout.sectionId !== sectionToRemove.id) return layout;
|
||||
|
||||
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||
);
|
||||
if (!removedSectionLayout) throw new Error("Layout not found");
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
sectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { addDynamicSectionCallback } from "./actions/add-dynamic-section";
|
||||
import type { RemoveDynamicSectionInput } from "./actions/remove-dynamic-section";
|
||||
import { removeDynamicSectionCallback } from "./actions/remove-dynamic-section";
|
||||
|
||||
interface UpdateDynamicOptions {
|
||||
itemId: string;
|
||||
newOptions: z.infer<typeof dynamicSectionOptionsSchema>;
|
||||
}
|
||||
|
||||
export const useDynamicSectionActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const addDynamicSection = useCallback(() => {
|
||||
updateBoard(addDynamicSectionCallback());
|
||||
}, [updateBoard]);
|
||||
|
||||
const updateDynamicSection = useCallback(
|
||||
({ itemId, newOptions }: UpdateDynamicOptions) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((item) =>
|
||||
item.id !== itemId || item.kind !== "dynamic" ? item : { ...item, options: newOptions },
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeDynamicSection = useCallback(
|
||||
(input: RemoveDynamicSectionInput) => {
|
||||
updateBoard(removeDynamicSectionCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return {
|
||||
addDynamicSection,
|
||||
updateDynamicSection,
|
||||
removeDynamicSection,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType, PropsWithChildren } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
import { loadGridEditorAsync } from "./grid-editor-loader";
|
||||
import { GridEditorRuntimeProvider } from "./grid-editor-runtime";
|
||||
|
||||
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
|
||||
|
||||
/**
|
||||
* Keeps the dnd-kit runtime outside the read-only bundle while guaranteeing
|
||||
* that every root and nested grid shares one drag-and-drop provider.
|
||||
*/
|
||||
export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const [Provider, setProvider] = useState<BoardGridEditorProviderComponent | null>(null);
|
||||
const [hasLoadError, setHasLoadError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode || Provider) return;
|
||||
|
||||
let cancelled = false;
|
||||
setHasLoadError(false);
|
||||
void loadGridEditorAsync()
|
||||
.then((module) => {
|
||||
if (!cancelled) setProvider(() => module.BoardGridEditorProvider);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHasLoadError(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [Provider, isEditMode]);
|
||||
|
||||
if (!isEditMode) {
|
||||
return <GridEditorRuntimeProvider status="idle">{children}</GridEditorRuntimeProvider>;
|
||||
}
|
||||
|
||||
if (hasLoadError) {
|
||||
return <GridEditorRuntimeProvider status="error">{children}</GridEditorRuntimeProvider>;
|
||||
}
|
||||
|
||||
if (!Provider) {
|
||||
return <GridEditorRuntimeProvider status="loading">{children}</GridEditorRuntimeProvider>;
|
||||
}
|
||||
|
||||
return (
|
||||
<GridEditorRuntimeProvider status="ready">
|
||||
<Provider>{children}</Provider>
|
||||
</GridEditorRuntimeProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import { getPointerProjectedShape, getSnappedGridCoordinates, getSnappedGridDelta } from "./coordinates";
|
||||
|
||||
describe("dnd grid coordinates", () => {
|
||||
test.each([0.75, 1, 1.25, 2])("maps transformed drag geometry at %sx scale", (scale) => {
|
||||
const target = { left: 137, top: -42, width: getLogicalTrackSize(8) * scale };
|
||||
const dragShape = {
|
||||
left: target.left + 3 * LOGICAL_GRID_PITCH * scale,
|
||||
top: target.top + 7 * LOGICAL_GRID_PITCH * scale,
|
||||
};
|
||||
|
||||
expect(getSnappedGridCoordinates({ dragShape, target, columnCount: 8 })).toEqual({ x: 3, y: 7 });
|
||||
});
|
||||
|
||||
test("uses the dragged shape origin so a non-center grab retains its pointer offset", () => {
|
||||
const scale = 0.75;
|
||||
const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale };
|
||||
const initialShape = { left: target.left, top: target.top };
|
||||
const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 };
|
||||
|
||||
expect(
|
||||
getSnappedGridCoordinates({
|
||||
dragShape: {
|
||||
left: initialShape.left + pointerDelta.x,
|
||||
top: initialShape.top + pointerDelta.y,
|
||||
},
|
||||
target,
|
||||
columnCount: 5,
|
||||
}),
|
||||
).toEqual({ x: 2, y: 3 });
|
||||
});
|
||||
|
||||
test.each([0.75, 1, 1.25, 2])("projects a pointer drag in visual coordinates at %sx scale", (scale) => {
|
||||
const target = { left: 496, top: -938, width: getLogicalTrackSize(8) * scale };
|
||||
const initialShape = {
|
||||
left: target.left + 4 * LOGICAL_GRID_PITCH * scale,
|
||||
top: target.top + 8 * LOGICAL_GRID_PITCH * scale,
|
||||
};
|
||||
const initialPointer = { x: initialShape.left + 134 * scale, y: initialShape.top + 89 * scale };
|
||||
const dragShape = getPointerProjectedShape(
|
||||
{ initialPointer, initialShape },
|
||||
{ x: initialPointer.x, y: initialPointer.y - LOGICAL_GRID_PITCH * scale },
|
||||
);
|
||||
|
||||
expect(dragShape.left).toBe(initialShape.left);
|
||||
expect(getSnappedGridCoordinates({ dragShape, target, columnCount: 8 })).toEqual({ x: 4, y: 7 });
|
||||
});
|
||||
|
||||
test.each([0.75, 1, 1.25, 2])("snaps resize pointer deltas at %sx scale", (visualScale) => {
|
||||
expect(
|
||||
getSnappedGridDelta({
|
||||
initial: { x: 19, y: 31 },
|
||||
current: {
|
||||
x: 19 - 2 * LOGICAL_GRID_PITCH * visualScale,
|
||||
y: 31 + 4 * LOGICAL_GRID_PITCH * visualScale,
|
||||
},
|
||||
visualScale,
|
||||
}),
|
||||
).toEqual({ x: -2, y: 4 });
|
||||
});
|
||||
|
||||
test("uses independently measured visual axes for transformed resize geometry", () => {
|
||||
expect(
|
||||
getSnappedGridDelta({
|
||||
initial: { x: 10, y: 20 },
|
||||
current: { x: 10 + LOGICAL_GRID_PITCH * 1.25, y: 20 - LOGICAL_GRID_PITCH * 1.5 },
|
||||
visualScale: { x: 1.25, y: 1.5 },
|
||||
}),
|
||||
).toEqual({ x: 1, y: -1 });
|
||||
});
|
||||
|
||||
test("rejects non-positive or non-finite scales", () => {
|
||||
expect(
|
||||
getSnappedGridCoordinates({
|
||||
dragShape: { left: 0, top: 0 },
|
||||
target: { left: 0, top: 0, width: 0 },
|
||||
columnCount: 4,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getSnappedGridDelta({ initial: { x: 0, y: 0 }, current: { x: 1, y: 1 }, visualScale: Number.NaN }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
|
||||
interface RectangleLike {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface DragProjectionOrigin {
|
||||
initialPointer: { x: number; y: number };
|
||||
initialShape: { left: number; top: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects the dragged shape from the grab point instead of dnd-kit's
|
||||
* feedback element. The latter can live in a CSS-zoomed overlay whose
|
||||
* bounding rectangle is in a different coordinate space from pointer events.
|
||||
*/
|
||||
export const getPointerProjectedShape = (origin: DragProjectionOrigin, currentPointer: { x: number; y: number }) => ({
|
||||
left: origin.initialShape.left + currentPointer.x - origin.initialPointer.x,
|
||||
top: origin.initialShape.top + currentPointer.y - origin.initialPointer.y,
|
||||
});
|
||||
|
||||
export const getSnappedGridCoordinates = ({
|
||||
dragShape,
|
||||
target,
|
||||
columnCount,
|
||||
}: {
|
||||
dragShape: Pick<RectangleLike, "left" | "top">;
|
||||
target: RectangleLike;
|
||||
columnCount: number;
|
||||
}) => {
|
||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||
const visualScale = logicalWidth > 0 ? target.width / logicalWidth : 0;
|
||||
if (!Number.isFinite(visualScale) || visualScale <= 0) return null;
|
||||
|
||||
return {
|
||||
x: Math.round((dragShape.left - target.left) / (LOGICAL_GRID_PITCH * visualScale)),
|
||||
y: Math.round((dragShape.top - target.top) / (LOGICAL_GRID_PITCH * visualScale)),
|
||||
};
|
||||
};
|
||||
|
||||
export const getSnappedGridDelta = ({
|
||||
current,
|
||||
initial,
|
||||
visualScale,
|
||||
}: {
|
||||
current: { x: number; y: number };
|
||||
initial: { x: number; y: number };
|
||||
visualScale: number | { x: number; y: number };
|
||||
}) => {
|
||||
const scale = typeof visualScale === "number" ? { x: visualScale, y: visualScale } : visualScale;
|
||||
if (!Number.isFinite(scale.x) || scale.x <= 0 || !Number.isFinite(scale.y) || scale.y <= 0) return null;
|
||||
return {
|
||||
x: Math.round((current.x - initial.x) / (LOGICAL_GRID_PITCH * scale.x)),
|
||||
y: Math.round((current.y - initial.y) / (LOGICAL_GRID_PITCH * scale.y)),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,743 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { doGridPlacementsOverlap } from "~/components/board/layout";
|
||||
import {
|
||||
beginGridTransaction,
|
||||
cancelGridTransaction,
|
||||
commitGridTransaction,
|
||||
previewGridMove,
|
||||
previewGridResize,
|
||||
} from "./engine";
|
||||
import type {
|
||||
GridPreviewResult,
|
||||
GridResizeDirection,
|
||||
GridTransaction,
|
||||
TransactionalGrid,
|
||||
TransactionalGridState,
|
||||
} from "./types";
|
||||
|
||||
interface TestPlacement {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
kind: "container" | "item";
|
||||
marker?: string;
|
||||
}
|
||||
|
||||
const placement = (
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
w = 1,
|
||||
h = 1,
|
||||
kind: TestPlacement["kind"] = "item",
|
||||
): TestPlacement => ({ id, x, y, w, h, kind, marker: `metadata:${id}` });
|
||||
|
||||
const grid = (
|
||||
id: string,
|
||||
columnCount: number,
|
||||
placements: readonly TestPlacement[],
|
||||
options: Pick<TransactionalGrid<TestPlacement>, "maxRowCount" | "ownerPlacementId" | "parentGridId"> = {},
|
||||
): TransactionalGrid<TestPlacement> => ({ id, columnCount, placements, ...options });
|
||||
|
||||
const state = (...grids: readonly TransactionalGrid<TestPlacement>[]): TransactionalGridState<TestPlacement> => ({
|
||||
grids,
|
||||
});
|
||||
|
||||
const accept = (result: GridPreviewResult<TestPlacement>): GridTransaction<TestPlacement> => {
|
||||
if (!result.accepted) throw new Error(`Expected preview to be accepted, received ${result.rejection.code}`);
|
||||
return result.transaction;
|
||||
};
|
||||
|
||||
const getGrid = (layout: TransactionalGridState<TestPlacement>, id: string) => {
|
||||
const found = layout.grids.find((candidate) => candidate.id === id);
|
||||
if (!found) throw new Error(`Missing test grid ${id}`);
|
||||
return found;
|
||||
};
|
||||
|
||||
const getPlacement = (layout: TransactionalGridState<TestPlacement>, id: string) => {
|
||||
for (const candidateGrid of layout.grids) {
|
||||
const found = candidateGrid.placements.find((candidate) => candidate.id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
throw new Error(`Missing test placement ${id}`);
|
||||
};
|
||||
|
||||
const coordinatesById = (layout: TransactionalGridState<TestPlacement>, gridId: string) =>
|
||||
Object.fromEntries(getGrid(layout, gridId).placements.map(({ id, x, y, w, h }) => [id, { x, y, w, h }]));
|
||||
|
||||
const expectValidState = (layout: TransactionalGridState<TestPlacement>) => {
|
||||
const allIds = new Set<string>();
|
||||
|
||||
for (const candidateGrid of layout.grids) {
|
||||
for (const [index, candidate] of candidateGrid.placements.entries()) {
|
||||
expect(allIds.has(candidate.id), `duplicate placement ${candidate.id}`).toBe(false);
|
||||
allIds.add(candidate.id);
|
||||
expect([candidate.x, candidate.y, candidate.w, candidate.h].every(Number.isInteger)).toBe(true);
|
||||
expect(candidate.x).toBeGreaterThanOrEqual(0);
|
||||
expect(candidate.y).toBeGreaterThanOrEqual(0);
|
||||
expect(candidate.w).toBeGreaterThanOrEqual(1);
|
||||
expect(candidate.h).toBeGreaterThanOrEqual(1);
|
||||
expect(candidate.x + candidate.w).toBeLessThanOrEqual(candidateGrid.columnCount);
|
||||
if (candidateGrid.maxRowCount !== undefined && candidateGrid.maxRowCount !== null) {
|
||||
expect(candidate.y + candidate.h).toBeLessThanOrEqual(candidateGrid.maxRowCount);
|
||||
}
|
||||
|
||||
for (const other of candidateGrid.placements.slice(index + 1)) {
|
||||
expect(
|
||||
doGridPlacementsOverlap(candidate, other),
|
||||
`${candidate.id} overlaps ${other.id} in ${candidateGrid.id}`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidateGrid of layout.grids) {
|
||||
if (!candidateGrid.ownerPlacementId) continue;
|
||||
const parent = getGrid(layout, candidateGrid.parentGridId ?? "");
|
||||
expect(parent.placements.some(({ id }) => id === candidateGrid.ownerPlacementId)).toBe(true);
|
||||
}
|
||||
};
|
||||
|
||||
describe("grid transaction setup", () => {
|
||||
test("captures detached snapshot and preview copies without mutating caller state", () => {
|
||||
const original = state(grid("root", 4, [placement("active", 0, 0), placement("other", 2, 3)]));
|
||||
const transaction = beginGridTransaction(original, { activeId: "active", sourceGridId: "root" });
|
||||
|
||||
expect(transaction.snapshot).toEqual(original);
|
||||
expect(transaction.preview).toEqual(original);
|
||||
expect(transaction.snapshot).not.toBe(original);
|
||||
expect(transaction.snapshot.grids[0]).not.toBe(original.grids[0]);
|
||||
expect(transaction.snapshot.grids[0]?.placements[0]).not.toBe(original.grids[0]?.placements[0]);
|
||||
expect(transaction.preview).not.toBe(transaction.snapshot);
|
||||
});
|
||||
|
||||
test("rejects a missing active item or stale source-grid hint", () => {
|
||||
const original = state(grid("root", 2, [placement("active", 0, 0)]));
|
||||
|
||||
expect(() => beginGridTransaction(original, { activeId: "missing" })).toThrow('Grid item "missing" does not exist');
|
||||
expect(() => beginGridTransaction(original, { activeId: "active", sourceGridId: "stale" })).toThrow(
|
||||
'belongs to grid "root", not "stale"',
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "duplicate grid ids",
|
||||
layout: state(grid("root", 2, [placement("a", 0, 0)]), grid("root", 2, [placement("b", 0, 0)])),
|
||||
message: "Duplicate grid id",
|
||||
},
|
||||
{
|
||||
name: "duplicate placement ids across grids",
|
||||
layout: state(grid("first", 2, [placement("a", 0, 0)]), grid("second", 2, [placement("a", 0, 0)])),
|
||||
message: "Duplicate grid item id",
|
||||
},
|
||||
{
|
||||
name: "overlaps",
|
||||
layout: state(grid("root", 3, [placement("a", 0, 0, 2), placement("b", 1, 0, 2)])),
|
||||
message: "overlap",
|
||||
},
|
||||
{
|
||||
name: "column overflow",
|
||||
layout: state(grid("root", 2, [placement("a", 1, 0, 2)])),
|
||||
message: "exceeds grid",
|
||||
},
|
||||
{
|
||||
name: "row overflow",
|
||||
layout: state(grid("root", 2, [placement("a", 0, 1, 1, 2)], { maxRowCount: 2 })),
|
||||
message: "exceeds grid",
|
||||
},
|
||||
{
|
||||
name: "missing hierarchy owner",
|
||||
layout: state(
|
||||
grid("root", 2, [placement("a", 0, 0)]),
|
||||
grid("child", 2, [], { parentGridId: "root", ownerPlacementId: "missing" }),
|
||||
),
|
||||
message: "Owner item",
|
||||
},
|
||||
{
|
||||
name: "cyclic hierarchy",
|
||||
layout: state(
|
||||
grid("first", 2, [placement("owns-second", 0, 0)], {
|
||||
parentGridId: "second",
|
||||
ownerPlacementId: "owns-first",
|
||||
}),
|
||||
grid("second", 2, [placement("owns-first", 0, 0)], {
|
||||
parentGridId: "first",
|
||||
ownerPlacementId: "owns-second",
|
||||
}),
|
||||
),
|
||||
message: "cycle",
|
||||
},
|
||||
])("rejects invalid initial state: $name", ({ layout, message }) => {
|
||||
expect(() => beginGridTransaction(layout, { activeId: layout.grids[0]?.placements[0]?.id ?? "missing" })).toThrow(
|
||||
message,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("same-grid movement", () => {
|
||||
test("keeps the exact snapshot for a no-op instead of compacting intentional gaps", () => {
|
||||
const original = state(grid("root", 4, [placement("active", 0, 3), placement("other", 2, 5)]));
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
const moved = accept(previewGridMove(transaction, { targetGridId: "root", x: 0, y: 3 }));
|
||||
|
||||
expect(moved.preview).toEqual(original);
|
||||
expect(moved.preview).not.toBe(original);
|
||||
});
|
||||
|
||||
test("swaps an equal-size item occupying the requested exact slot", () => {
|
||||
const original = state(
|
||||
grid("root", 4, [placement("active", 0, 0), placement("target", 1, 0), placement("untouched", 2, 0)]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(moved.preview, "root")).toEqual({
|
||||
active: { x: 1, y: 0, w: 1, h: 1 },
|
||||
target: { x: 0, y: 0, w: 1, h: 1 },
|
||||
untouched: { x: 2, y: 0, w: 1, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("swaps different-size items when both resulting placements remain valid", () => {
|
||||
const original = state(grid("root", 4, [placement("wide", 0, 0, 2), placement("compact", 2, 0)]));
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "wide" }), {
|
||||
targetGridId: "root",
|
||||
x: 2,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(moved.preview, "root")).toEqual({
|
||||
wide: { x: 2, y: 0, w: 2, h: 1 },
|
||||
compact: { x: 0, y: 0, w: 1, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("uses pinned reflow when an exact-slot swap would exceed the grid bounds", () => {
|
||||
const original = state(grid("root", 4, [placement("compact", 3, 0), placement("wide", 0, 0, 2)]));
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "compact" }), {
|
||||
targetGridId: "root",
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(moved.preview, "root")).toEqual({
|
||||
compact: { x: 0, y: 0, w: 1, h: 1 },
|
||||
wide: { x: 0, y: 1, w: 2, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("pushes a different-size collision without compacting unrelated gaps", () => {
|
||||
const original = state(
|
||||
grid("root", 4, [placement("active", 0, 0), placement("wide", 1, 0, 2), placement("lower-left", 0, 1)]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(moved.preview, "root")).toEqual({
|
||||
active: { x: 1, y: 0, w: 1, h: 1 },
|
||||
wide: { x: 1, y: 1, w: 2, h: 1 },
|
||||
"lower-left": { x: 0, y: 1, w: 1, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("uses pinned reflow for a partial overlap even when sizes match", () => {
|
||||
const original = state(grid("root", 5, [placement("active", 0, 0, 2), placement("target", 2, 0, 2)]));
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 1, y: 0, w: 2, h: 1 });
|
||||
expect(getPlacement(moved.preview, "target")).toMatchObject({ x: 2, y: 1, w: 2, h: 1 });
|
||||
});
|
||||
|
||||
test("preserves intentional empty rows outside the collision chain", () => {
|
||||
const original = state(
|
||||
grid("root", 4, [placement("active", 0, 0), placement("collision", 1, 0, 2), placement("far", 3, 8)]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "collision")).toMatchObject({ x: 1, y: 1 });
|
||||
expect(getPlacement(moved.preview, "far")).toMatchObject({ x: 3, y: 8 });
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ requested: { x: -20, y: -10 }, expected: { x: 0, y: 0 } },
|
||||
{ requested: { x: 99, y: 99 }, expected: { x: 2, y: 3 } },
|
||||
{ requested: { x: 2.9, y: 1.9 }, expected: { x: 2, y: 1 } },
|
||||
])("clamps and quantizes requested coordinates: $requested", ({ requested, expected }) => {
|
||||
const original = state(grid("root", 4, [placement("active", 1, 1, 2, 2)], { maxRowCount: 5 }));
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
...requested,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "active")).toMatchObject(expected);
|
||||
});
|
||||
|
||||
test("produces the same result regardless of backing-array order", () => {
|
||||
const placements = [
|
||||
placement("active", 3, 0),
|
||||
placement("left", 0, 2),
|
||||
placement("middle", 1, 2),
|
||||
placement("right", 2, 2),
|
||||
];
|
||||
const forward = state(grid("root", 4, placements));
|
||||
const reversed = state(grid("root", 4, placements.toReversed()));
|
||||
const input = { targetGridId: "root", x: 0, y: 0 };
|
||||
|
||||
const first = accept(previewGridMove(beginGridTransaction(forward, { activeId: "active" }), input));
|
||||
const second = accept(previewGridMove(beginGridTransaction(reversed, { activeId: "active" }), input));
|
||||
|
||||
expect(coordinatesById(first.preview, "root")).toEqual(coordinatesById(second.preview, "root"));
|
||||
});
|
||||
|
||||
test("recomputes every chained preview from the interaction snapshot", () => {
|
||||
const original = state(
|
||||
grid("root", 3, [placement("active", 0, 0), placement("middle", 1, 0), placement("last", 2, 0)]),
|
||||
);
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
const first = accept(previewGridMove(transaction, { targetGridId: "root", x: 1, y: 0 }));
|
||||
const second = accept(previewGridMove(first, { targetGridId: "root", x: 2, y: 0 }));
|
||||
|
||||
expect(coordinatesById(first.preview, "root")).toEqual({
|
||||
active: { x: 1, y: 0, w: 1, h: 1 },
|
||||
middle: { x: 0, y: 0, w: 1, h: 1 },
|
||||
last: { x: 2, y: 0, w: 1, h: 1 },
|
||||
});
|
||||
expect(coordinatesById(second.preview, "root")).toEqual({
|
||||
active: { x: 2, y: 0, w: 1, h: 1 },
|
||||
middle: { x: 1, y: 0, w: 1, h: 1 },
|
||||
last: { x: 0, y: 0, w: 1, h: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-grid movement", () => {
|
||||
test("updates source and target atomically without compacting the source gap", () => {
|
||||
const original = state(
|
||||
grid("source", 2, [placement("active", 0, 0), placement("source-neighbor", 0, 1)]),
|
||||
grid("target", 2, [placement("target-neighbor", 0, 0)]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "target",
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(moved.preview, "source")).toEqual({
|
||||
"source-neighbor": { x: 0, y: 1, w: 1, h: 1 },
|
||||
});
|
||||
expect(coordinatesById(moved.preview, "target")).toEqual({
|
||||
"target-neighbor": { x: 0, y: 1, w: 1, h: 1 },
|
||||
active: { x: 0, y: 0, w: 1, h: 1 },
|
||||
});
|
||||
expect(getPlacement(moved.preview, "active").marker).toBe("metadata:active");
|
||||
expect(original).toEqual(
|
||||
state(
|
||||
grid("source", 2, [placement("active", 0, 0), placement("source-neighbor", 0, 1)]),
|
||||
grid("target", 2, [placement("target-neighbor", 0, 0)]),
|
||||
),
|
||||
);
|
||||
expectValidState(moved.preview);
|
||||
});
|
||||
|
||||
test("chained target changes do not leak an earlier cross-grid preview", () => {
|
||||
const original = state(
|
||||
grid("source", 2, [placement("active", 0, 0)]),
|
||||
grid("first-target", 2, [placement("first-neighbor", 0, 0)]),
|
||||
grid("second-target", 2, [placement("second-neighbor", 0, 0)]),
|
||||
);
|
||||
const first = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "first-target",
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
const second = accept(previewGridMove(first, { targetGridId: "second-target", x: 0, y: 0 }));
|
||||
|
||||
expect(getGrid(second.preview, "first-target").placements).toEqual(getGrid(original, "first-target").placements);
|
||||
expect(getGrid(second.preview, "source").placements).toHaveLength(0);
|
||||
expect(getPlacement(second.preview, "active")).toMatchObject({ x: 0, y: 0 });
|
||||
expect(getPlacement(second.preview, "second-neighbor")).toMatchObject({ x: 0, y: 1 });
|
||||
});
|
||||
|
||||
test("moves a container's owned grid with it and supports commit/cancel", () => {
|
||||
const original = state(
|
||||
grid("source", 3, [placement("container", 0, 0, 2, 2, "container")]),
|
||||
grid("target", 3, []),
|
||||
grid("container-content", 2, [placement("child", 0, 0)], {
|
||||
parentGridId: "source",
|
||||
ownerPlacementId: "container",
|
||||
}),
|
||||
);
|
||||
const transaction = beginGridTransaction(original, { activeId: "container" });
|
||||
const moved = accept(previewGridMove(transaction, { targetGridId: "target", x: 1, y: 2 }));
|
||||
|
||||
expect(getGrid(moved.preview, "container-content").parentGridId).toBe("target");
|
||||
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 2, kind: "container" });
|
||||
expect(cancelGridTransaction(moved)).toEqual(original);
|
||||
expect(commitGridTransaction(moved)).toEqual(moved.preview);
|
||||
expect(commitGridTransaction(moved)).not.toBe(moved.preview);
|
||||
});
|
||||
|
||||
test.each(["child", "grandchild"])("rejects a container move into its %s grid", (targetGridId) => {
|
||||
const original = state(
|
||||
grid("root", 4, [placement("container", 0, 0, 2, 2, "container")]),
|
||||
grid("child", 3, [placement("nested-container", 0, 0, 2, 2, "container")], {
|
||||
parentGridId: "root",
|
||||
ownerPlacementId: "container",
|
||||
}),
|
||||
grid("grandchild", 2, [], {
|
||||
parentGridId: "child",
|
||||
ownerPlacementId: "nested-container",
|
||||
}),
|
||||
);
|
||||
const transaction = beginGridTransaction(original, { activeId: "container" });
|
||||
const result = previewGridMove(transaction, { targetGridId, x: 0, y: 0 });
|
||||
|
||||
expect(result.accepted).toBe(false);
|
||||
if (result.accepted) throw new Error("Expected descendant move rejection");
|
||||
expect(result.rejection.code).toBe("descendant-target");
|
||||
expect(result.transaction).toBe(transaction);
|
||||
});
|
||||
|
||||
test("runs caller guards with hierarchy context and preserves custom rejection details", () => {
|
||||
const original = state(grid("source", 2, [placement("active", 0, 0)]), grid("target", 2, []));
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
let observedTarget = "";
|
||||
const guarded = previewGridMove(
|
||||
transaction,
|
||||
{ targetGridId: "target", x: 0, y: 0 },
|
||||
{
|
||||
guards: [
|
||||
(context) => {
|
||||
observedTarget = context.targetGrid.id;
|
||||
expect(context.active.id).toBe("active");
|
||||
expect(context.sourceGrid.id).toBe("source");
|
||||
expect(context.targetIsDescendant).toBe(false);
|
||||
expect(context.snapshot).toBe(transaction.snapshot);
|
||||
return true;
|
||||
},
|
||||
() => ({ code: "locked-container", message: "Unlock the container first" }),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(observedTarget).toBe("target");
|
||||
expect(guarded.accepted).toBe(false);
|
||||
if (guarded.accepted) throw new Error("Expected custom guard rejection");
|
||||
expect(guarded.rejection).toEqual({ code: "locked-container", message: "Unlock the container first" });
|
||||
|
||||
const booleanRejection = previewGridMove(
|
||||
transaction,
|
||||
{ targetGridId: "target", x: 0, y: 0 },
|
||||
{
|
||||
guards: [() => false],
|
||||
},
|
||||
);
|
||||
expect(booleanRejection.accepted).toBe(false);
|
||||
if (booleanRejection.accepted) throw new Error("Expected boolean guard rejection");
|
||||
expect(booleanRejection.rejection.code).toBe("guard-rejected");
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "unknown grid",
|
||||
original: state(grid("source", 2, [placement("active", 0, 0)])),
|
||||
input: { targetGridId: "missing", x: 0, y: 0 },
|
||||
code: "unknown-target-grid",
|
||||
},
|
||||
{
|
||||
name: "destination narrower than the item",
|
||||
original: state(grid("source", 3, [placement("active", 0, 0, 2)]), grid("target", 1, [])),
|
||||
input: { targetGridId: "target", x: 0, y: 0 },
|
||||
code: "item-too-large",
|
||||
},
|
||||
{
|
||||
name: "reflow beyond destination row limit",
|
||||
original: state(
|
||||
grid("source", 2, [placement("active", 0, 0, 2)]),
|
||||
grid("target", 2, [placement("target", 0, 0, 2)], { maxRowCount: 1 }),
|
||||
),
|
||||
input: { targetGridId: "target", x: 0, y: 0 },
|
||||
code: "row-limit",
|
||||
},
|
||||
{
|
||||
name: "non-finite coordinate",
|
||||
original: state(grid("source", 2, [placement("active", 0, 0)])),
|
||||
input: { targetGridId: "source", x: Number.NaN, y: 0 },
|
||||
code: "invalid-coordinate",
|
||||
},
|
||||
])("rejects $name without changing the transaction", ({ original, input, code }) => {
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
const result = previewGridMove(transaction, input);
|
||||
|
||||
expect(result.accepted).toBe(false);
|
||||
if (result.accepted) throw new Error("Expected move rejection");
|
||||
expect(result.rejection.code).toBe(code);
|
||||
expect(result.transaction).toBe(transaction);
|
||||
expect(result.transaction.preview).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("eight-direction resize", () => {
|
||||
test.each<{
|
||||
direction: GridResizeDirection;
|
||||
deltaColumns: number;
|
||||
deltaRows: number;
|
||||
expected: Pick<TestPlacement, "x" | "y" | "w" | "h">;
|
||||
}>([
|
||||
{ direction: "n", deltaColumns: 0, deltaRows: -1, expected: { x: 3, y: 2, w: 4, h: 5 } },
|
||||
{ direction: "ne", deltaColumns: 1, deltaRows: -1, expected: { x: 3, y: 2, w: 5, h: 5 } },
|
||||
{ direction: "e", deltaColumns: 1, deltaRows: 0, expected: { x: 3, y: 3, w: 5, h: 4 } },
|
||||
{ direction: "se", deltaColumns: 1, deltaRows: 1, expected: { x: 3, y: 3, w: 5, h: 5 } },
|
||||
{ direction: "s", deltaColumns: 0, deltaRows: 1, expected: { x: 3, y: 3, w: 4, h: 5 } },
|
||||
{ direction: "sw", deltaColumns: -1, deltaRows: 1, expected: { x: 2, y: 3, w: 5, h: 5 } },
|
||||
{ direction: "w", deltaColumns: -1, deltaRows: 0, expected: { x: 2, y: 3, w: 5, h: 4 } },
|
||||
{ direction: "nw", deltaColumns: -1, deltaRows: -1, expected: { x: 2, y: 2, w: 5, h: 5 } },
|
||||
])("resizes from $direction while anchoring the opposite edge", ({ expected, ...input }) => {
|
||||
const original = state(grid("root", 12, [placement("active", 3, 3, 4, 4)], { maxRowCount: 12 }));
|
||||
const resized = accept(previewGridResize(beginGridTransaction(original, { activeId: "active" }), input));
|
||||
|
||||
expect(getPlacement(resized.preview, "active")).toMatchObject(expected);
|
||||
});
|
||||
|
||||
test("clamps a north-west shrink to minimum size while keeping right and bottom anchored", () => {
|
||||
const original = state(grid("root", 12, [placement("active", 3, 3, 4, 4)], { maxRowCount: 12 }));
|
||||
const resized = accept(
|
||||
previewGridResize(beginGridTransaction(original, { activeId: "active" }), {
|
||||
direction: "nw",
|
||||
deltaColumns: 10,
|
||||
deltaRows: 10,
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(resized.preview, "active")).toMatchObject({ x: 5, y: 5, w: 2, h: 2 });
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ direction: "nw" as const, deltaColumns: -99, deltaRows: -99, expected: { x: 0, y: 0, w: 7, h: 7 } },
|
||||
{ direction: "se" as const, deltaColumns: 99, deltaRows: 99, expected: { x: 3, y: 3, w: 9, h: 9 } },
|
||||
])("clamps $direction growth to grid bounds", ({ expected, ...input }) => {
|
||||
const original = state(grid("root", 12, [placement("active", 3, 3, 4, 4)], { maxRowCount: 12 }));
|
||||
const resized = accept(previewGridResize(beginGridTransaction(original, { activeId: "active" }), input));
|
||||
|
||||
expect(getPlacement(resized.preview, "active")).toMatchObject(expected);
|
||||
});
|
||||
|
||||
test("honors explicit maximum dimensions", () => {
|
||||
const original = state(grid("root", 12, [placement("active", 3, 3, 4, 4)], { maxRowCount: 12 }));
|
||||
const resized = accept(
|
||||
previewGridResize(beginGridTransaction(original, { activeId: "active" }), {
|
||||
direction: "se",
|
||||
deltaColumns: 99,
|
||||
deltaRows: 99,
|
||||
maxWidth: 6,
|
||||
maxHeight: 5,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(resized.preview, "active")).toMatchObject({ x: 3, y: 3, w: 6, h: 5 });
|
||||
});
|
||||
|
||||
test("pins the resized item and deterministically pushes collisions", () => {
|
||||
const original = state(
|
||||
grid("root", 3, [placement("active", 0, 0, 2), placement("neighbor", 0, 1, 2), placement("side", 2, 2)]),
|
||||
);
|
||||
const resized = accept(
|
||||
previewGridResize(beginGridTransaction(original, { activeId: "active" }), {
|
||||
direction: "s",
|
||||
deltaColumns: 0,
|
||||
deltaRows: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(resized.preview, "root")).toEqual({
|
||||
active: { x: 0, y: 0, w: 2, h: 3 },
|
||||
neighbor: { x: 0, y: 3, w: 2, h: 1 },
|
||||
side: { x: 2, y: 2, w: 1, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects resize when collision reflow would exceed the row limit", () => {
|
||||
const original = state(
|
||||
grid("root", 2, [placement("active", 0, 0, 2), placement("neighbor", 0, 1, 2, 2)], {
|
||||
maxRowCount: 3,
|
||||
}),
|
||||
);
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
const result = previewGridResize(transaction, {
|
||||
direction: "s",
|
||||
deltaColumns: 0,
|
||||
deltaRows: 1,
|
||||
});
|
||||
|
||||
expect(result.accepted).toBe(false);
|
||||
if (result.accepted) throw new Error("Expected row-limit rejection");
|
||||
expect(result.rejection.code).toBe("row-limit");
|
||||
expect(result.transaction).toBe(transaction);
|
||||
});
|
||||
|
||||
test("rejects non-finite deltas and geometrically impossible constraints", () => {
|
||||
const original = state(grid("root", 4, [placement("active", 0, 0, 2, 2)]));
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
const invalidDelta = previewGridResize(transaction, {
|
||||
direction: "e",
|
||||
deltaColumns: Number.POSITIVE_INFINITY,
|
||||
deltaRows: 0,
|
||||
});
|
||||
const impossible = previewGridResize(transaction, {
|
||||
direction: "w",
|
||||
deltaColumns: 0,
|
||||
deltaRows: 0,
|
||||
minWidth: 5,
|
||||
maxWidth: 5,
|
||||
});
|
||||
|
||||
expect(invalidDelta.accepted).toBe(false);
|
||||
if (invalidDelta.accepted) throw new Error("Expected invalid-coordinate rejection");
|
||||
expect(invalidDelta.rejection.code).toBe("invalid-coordinate");
|
||||
expect(impossible.accepted).toBe(false);
|
||||
if (impossible.accepted) throw new Error("Expected resize-constraints rejection");
|
||||
expect(impossible.rejection.code).toBe("resize-constraints");
|
||||
});
|
||||
|
||||
test.each([{ minWidth: 0 }, { minHeight: 1.5 }, { minWidth: 3, maxWidth: 2 }, { minHeight: 3, maxHeight: 2 }])(
|
||||
"throws on invalid programmer-supplied constraints: $minWidth $minHeight",
|
||||
(constraints) => {
|
||||
const original = state(grid("root", 4, [placement("active", 0, 0, 2, 2)]));
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
|
||||
expect(() =>
|
||||
previewGridResize(transaction, {
|
||||
direction: "se",
|
||||
deltaColumns: 0,
|
||||
deltaRows: 0,
|
||||
...constraints,
|
||||
}),
|
||||
).toThrow(RangeError);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("transaction completion", () => {
|
||||
test("a rejection keeps the latest accepted preview, cancel restores start, and commit detaches output", () => {
|
||||
const original = state(grid("root", 3, [placement("active", 0, 0), placement("other", 1, 0)]));
|
||||
const started = beginGridTransaction(original, { activeId: "active" });
|
||||
const moved = accept(previewGridMove(started, { targetGridId: "root", x: 1, y: 0 }));
|
||||
const rejected = previewGridMove(moved, { targetGridId: "missing", x: 0, y: 0 });
|
||||
|
||||
expect(rejected.accepted).toBe(false);
|
||||
if (rejected.accepted) throw new Error("Expected rejection");
|
||||
expect(rejected.transaction).toBe(moved);
|
||||
expect(rejected.transaction.preview).toEqual(moved.preview);
|
||||
|
||||
const committed = commitGridTransaction(rejected.transaction);
|
||||
const cancelled = cancelGridTransaction(rejected.transaction);
|
||||
expect(committed).toEqual(moved.preview);
|
||||
expect(committed).not.toBe(moved.preview);
|
||||
expect(cancelled).toEqual(original);
|
||||
expect(cancelled).not.toBe(rejected.transaction.snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interaction invariant matrix", () => {
|
||||
test("preserves invariants across every sampled same-grid and cross-grid coordinate", () => {
|
||||
const original = state(
|
||||
grid(
|
||||
"source",
|
||||
6,
|
||||
[
|
||||
placement("active", 0, 0, 2, 2),
|
||||
placement("source-a", 2, 0, 2),
|
||||
placement("source-b", 4, 0, 2, 2),
|
||||
placement("source-c", 2, 1, 2),
|
||||
],
|
||||
{ maxRowCount: 10 },
|
||||
),
|
||||
grid("target", 5, [placement("target-a", 0, 0), placement("target-b", 1, 0, 2, 2)], {
|
||||
maxRowCount: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const targetGridId of ["source", "target"]) {
|
||||
for (let x = -3; x <= 8; x += 1) {
|
||||
for (let y = -2; y <= 11; y += 1) {
|
||||
const result = previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
if (!result.accepted) {
|
||||
expect(result.rejection.code).toBe("row-limit");
|
||||
continue;
|
||||
}
|
||||
|
||||
expectValidState(result.transaction.preview);
|
||||
expect(getPlacement(result.transaction.preview, "active").marker).toBe("metadata:active");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves invariants across every direction and representative signed deltas", () => {
|
||||
const original = state(grid("root", 8, [placement("active", 2, 2, 3, 3)], { maxRowCount: 9 }));
|
||||
const directions: readonly GridResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
|
||||
const deltas = [-20, -3, -1, 0, 1, 3, 20];
|
||||
|
||||
for (const direction of directions) {
|
||||
for (const deltaColumns of deltas) {
|
||||
for (const deltaRows of deltas) {
|
||||
const result = previewGridResize(beginGridTransaction(original, { activeId: "active" }), {
|
||||
direction,
|
||||
deltaColumns,
|
||||
deltaRows,
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
maxWidth: 6,
|
||||
maxHeight: 7,
|
||||
});
|
||||
expect(result.accepted).toBe(true);
|
||||
if (!result.accepted) throw new Error(`Unexpected ${result.rejection.code} for ${direction}`);
|
||||
expectValidState(result.transaction.preview);
|
||||
expect(getPlacement(result.transaction.preview, "active").marker).toBe("metadata:active");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,497 @@
|
||||
import { doGridPlacementsOverlap, getLayoutRowCount, normalizeGridPlacement } from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import type {
|
||||
BeginGridTransactionInput,
|
||||
GridMoveGuard,
|
||||
GridMoveInput,
|
||||
GridMoveOptions,
|
||||
GridOperationRejection,
|
||||
GridPreviewResult,
|
||||
GridResizeDirection,
|
||||
GridResizeInput,
|
||||
GridTransaction,
|
||||
TransactionalGrid,
|
||||
TransactionalGridState,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Starts an immutable interaction transaction. The returned snapshot is the
|
||||
* rollback source for dnd-kit's `onDragCancel` and the baseline for every
|
||||
* `onDragMove` / `onDragOver` preview.
|
||||
*/
|
||||
export const beginGridTransaction = <TPlacement extends GridPlacement>(
|
||||
state: TransactionalGridState<TPlacement>,
|
||||
input: BeginGridTransactionInput,
|
||||
): GridTransaction<TPlacement> => {
|
||||
validateGridState(state);
|
||||
|
||||
const located = findPlacement(state, input.activeId);
|
||||
if (!located) throw new Error(`Grid item "${input.activeId}" does not exist`);
|
||||
if (input.sourceGridId !== undefined && located.grid.id !== input.sourceGridId) {
|
||||
throw new Error(`Grid item "${input.activeId}" belongs to grid "${located.grid.id}", not "${input.sourceGridId}"`);
|
||||
}
|
||||
|
||||
const snapshot = cloneGridState(state);
|
||||
return {
|
||||
activeId: input.activeId,
|
||||
sourceGridId: located.grid.id,
|
||||
snapshot,
|
||||
preview: cloneGridState(snapshot),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes a move preview from the transaction snapshot. Same-grid exact-slot
|
||||
* drops swap items when both resulting placements remain valid; every other
|
||||
* move pins the active item and pushes only actual collisions. Intentional
|
||||
* empty rows remain untouched.
|
||||
*/
|
||||
export const previewGridMove = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
input: GridMoveInput,
|
||||
options: GridMoveOptions<TPlacement> = {},
|
||||
): GridPreviewResult<TPlacement> => {
|
||||
const sourceGrid = getGrid(transaction.snapshot, transaction.sourceGridId);
|
||||
const targetGrid = findGrid(transaction.snapshot, input.targetGridId);
|
||||
if (!targetGrid) {
|
||||
return rejected(transaction, {
|
||||
code: "unknown-target-grid",
|
||||
message: `Grid "${input.targetGridId}" does not exist`,
|
||||
});
|
||||
}
|
||||
|
||||
const active = getPlacement(sourceGrid, transaction.activeId);
|
||||
const targetIsDescendant = isTargetWithinActiveSubtree(transaction.snapshot, targetGrid.id, transaction.activeId);
|
||||
if (targetIsDescendant) {
|
||||
return rejected(transaction, {
|
||||
code: "descendant-target",
|
||||
message: `Grid "${targetGrid.id}" is inside item "${active.id}"`,
|
||||
});
|
||||
}
|
||||
|
||||
const guardRejection = evaluateMoveGuards(options.guards ?? [], {
|
||||
active,
|
||||
sourceGrid,
|
||||
targetGrid,
|
||||
snapshot: transaction.snapshot,
|
||||
targetIsDescendant,
|
||||
});
|
||||
if (guardRejection) return rejected(transaction, guardRejection);
|
||||
|
||||
if (!Number.isFinite(input.x) || !Number.isFinite(input.y)) {
|
||||
return rejected(transaction, { code: "invalid-coordinate" });
|
||||
}
|
||||
if (active.w > targetGrid.columnCount || active.h > getMaximumRows(targetGrid)) {
|
||||
return rejected(transaction, { code: "item-too-large" });
|
||||
}
|
||||
|
||||
const nextActive = clampMovePlacement(active, input, targetGrid);
|
||||
const preview =
|
||||
sourceGrid.id === targetGrid.id
|
||||
? previewSameGridMove(transaction.snapshot, sourceGrid, active, nextActive)
|
||||
: previewCrossGridMove(transaction.snapshot, sourceGrid, targetGrid, active, nextActive);
|
||||
|
||||
if (!fitsRowLimits(preview)) return rejected(transaction, { code: "row-limit" });
|
||||
validateGridState(preview);
|
||||
return accepted(transaction, preview);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes an eight-direction resize preview. Deltas describe movement of the
|
||||
* selected handle, while the opposite edge stays anchored. Bounds and caller
|
||||
* constraints are applied before pinned collision reflow.
|
||||
*/
|
||||
export const previewGridResize = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
input: GridResizeInput,
|
||||
): GridPreviewResult<TPlacement> => {
|
||||
const grid = getGrid(transaction.snapshot, transaction.sourceGridId);
|
||||
const active = getPlacement(grid, transaction.activeId);
|
||||
|
||||
if (!Number.isFinite(input.deltaColumns) || !Number.isFinite(input.deltaRows)) {
|
||||
return rejected(transaction, { code: "invalid-coordinate" });
|
||||
}
|
||||
|
||||
const constraints = getResizeConstraints(input, grid);
|
||||
const resized = resizePlacement(active, input.direction, input, constraints, grid);
|
||||
if (!resized) return rejected(transaction, { code: "resize-constraints" });
|
||||
|
||||
const preview = cloneGridState(transaction.snapshot);
|
||||
const previewGrid = getGrid(preview, grid.id);
|
||||
previewGrid.placements = areCoordinatesEqual(active, resized)
|
||||
? previewGrid.placements
|
||||
: resolvePinnedGridCollisions(previewGrid.placements, resized, previewGrid.columnCount);
|
||||
|
||||
if (!fitsRowLimits(preview)) return rejected(transaction, { code: "row-limit" });
|
||||
validateGridState(preview);
|
||||
return accepted(transaction, preview);
|
||||
};
|
||||
|
||||
/** Returns the latest accepted preview as one atomic state update. */
|
||||
export const commitGridTransaction = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
): TransactionalGridState<TPlacement> => cloneGridState(transaction.preview);
|
||||
|
||||
/** Restores the exact interaction-start snapshot. */
|
||||
export const cancelGridTransaction = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
): TransactionalGridState<TPlacement> => cloneGridState(transaction.snapshot);
|
||||
|
||||
const previewSameGridMove = <TPlacement extends GridPlacement>(
|
||||
state: TransactionalGridState<TPlacement>,
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
active: TPlacement,
|
||||
nextActive: TPlacement,
|
||||
) => {
|
||||
const preview = cloneGridState(state);
|
||||
const previewGrid = getGrid(preview, grid.id);
|
||||
if (areCoordinatesEqual(active, nextActive)) return preview;
|
||||
|
||||
const displaced = grid.placements.find(
|
||||
(placement) => placement.id !== active.id && placement.x === nextActive.x && placement.y === nextActive.y,
|
||||
);
|
||||
|
||||
const swapped = displaced
|
||||
? grid.placements.map((placement) => {
|
||||
if (placement.id === active.id) return { ...placement, x: nextActive.x, y: nextActive.y };
|
||||
if (placement.id === displaced.id) return { ...placement, x: active.x, y: active.y };
|
||||
return { ...placement };
|
||||
})
|
||||
: null;
|
||||
|
||||
previewGrid.placements =
|
||||
swapped && placementsFitGrid(swapped, grid)
|
||||
? swapped
|
||||
: resolvePinnedGridCollisions(grid.placements, nextActive, grid.columnCount);
|
||||
return preview;
|
||||
};
|
||||
|
||||
const placementsFitGrid = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
) =>
|
||||
placements.every(
|
||||
(placement) =>
|
||||
placement.x >= 0 &&
|
||||
placement.y >= 0 &&
|
||||
placement.x + placement.w <= grid.columnCount &&
|
||||
(grid.maxRowCount === undefined || grid.maxRowCount === null || placement.y + placement.h <= grid.maxRowCount),
|
||||
) &&
|
||||
placements.every((placement, index) =>
|
||||
placements.slice(index + 1).every((other) => !doGridPlacementsOverlap(placement, other)),
|
||||
);
|
||||
|
||||
const previewCrossGridMove = <TPlacement extends GridPlacement>(
|
||||
state: TransactionalGridState<TPlacement>,
|
||||
sourceGrid: TransactionalGrid<TPlacement>,
|
||||
targetGrid: TransactionalGrid<TPlacement>,
|
||||
active: TPlacement,
|
||||
nextActive: TPlacement,
|
||||
) => {
|
||||
const preview = cloneGridState(state);
|
||||
const previewSource = getGrid(preview, sourceGrid.id);
|
||||
const previewTarget = getGrid(preview, targetGrid.id);
|
||||
|
||||
previewSource.placements = sourceGrid.placements
|
||||
.filter((placement) => placement.id !== active.id)
|
||||
.map((placement) => ({ ...placement }));
|
||||
previewTarget.placements = resolvePinnedGridCollisions(
|
||||
[...targetGrid.placements, nextActive],
|
||||
nextActive,
|
||||
targetGrid.columnCount,
|
||||
);
|
||||
|
||||
preview.grids = preview.grids.map((grid) =>
|
||||
grid.ownerPlacementId === active.id ? { ...grid, parentGridId: targetGrid.id } : grid,
|
||||
);
|
||||
return preview;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pins the active placement, keeps every non-colliding item at its persisted
|
||||
* coordinates, and pushes only the deterministic collision chain downward.
|
||||
*/
|
||||
export const resolvePinnedGridCollisions = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
pinnedPlacement: TPlacement,
|
||||
columnCount: number,
|
||||
): TPlacement[] => {
|
||||
const normalized = placements.map((placement) =>
|
||||
normalizeGridPlacement(placement.id === pinnedPlacement.id ? pinnedPlacement : placement, columnCount),
|
||||
);
|
||||
const pinned = normalized.find((placement) => placement.id === pinnedPlacement.id);
|
||||
if (!pinned) throw new Error(`Pinned grid item "${pinnedPlacement.id}" does not exist`);
|
||||
|
||||
const placed: TPlacement[] = [pinned];
|
||||
const candidates = normalized.filter((placement) => placement.id !== pinned.id).toSorted(comparePlacements);
|
||||
for (const candidate of candidates) {
|
||||
let resolved = candidate;
|
||||
for (;;) {
|
||||
const collisions = placed.filter((placement) => doGridPlacementsOverlap(resolved, placement));
|
||||
if (collisions.length === 0) break;
|
||||
resolved = {
|
||||
...resolved,
|
||||
y: Math.max(...collisions.map((placement) => placement.y + placement.h)),
|
||||
};
|
||||
}
|
||||
placed.push(resolved);
|
||||
}
|
||||
|
||||
const byId = new Map(placed.map((placement) => [placement.id, placement]));
|
||||
return normalized.map((placement) => byId.get(placement.id) ?? placement);
|
||||
};
|
||||
|
||||
const comparePlacements = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.y - second.y || first.x - second.x || first.id.localeCompare(second.id);
|
||||
|
||||
const clampMovePlacement = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
input: GridMoveInput,
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
) => {
|
||||
const maximumX = grid.columnCount - placement.w;
|
||||
const maximumY = getMaximumRows(grid) - placement.h;
|
||||
return {
|
||||
...placement,
|
||||
x: clamp(Math.trunc(input.x), 0, maximumX),
|
||||
y: clamp(Math.trunc(input.y), 0, maximumY),
|
||||
};
|
||||
};
|
||||
|
||||
interface ResolvedResizeConstraints {
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
const getResizeConstraints = <TPlacement extends GridPlacement>(
|
||||
input: GridResizeInput,
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
): ResolvedResizeConstraints => {
|
||||
const minWidth = toPositiveInteger(input.minWidth ?? 1, "minWidth");
|
||||
const minHeight = toPositiveInteger(input.minHeight ?? 1, "minHeight");
|
||||
const maxWidth = toPositiveInteger(input.maxWidth ?? grid.columnCount, "maxWidth");
|
||||
const maxHeight = toPositiveInteger(input.maxHeight ?? getMaximumRows(grid), "maxHeight");
|
||||
if (maxWidth < minWidth) throw new RangeError("maxWidth must be greater than or equal to minWidth");
|
||||
if (maxHeight < minHeight) throw new RangeError("maxHeight must be greater than or equal to minHeight");
|
||||
|
||||
return { minWidth, minHeight, maxWidth, maxHeight };
|
||||
};
|
||||
|
||||
const resizePlacement = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
direction: GridResizeDirection,
|
||||
input: GridResizeInput,
|
||||
constraints: ResolvedResizeConstraints,
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
): TPlacement | null => {
|
||||
const deltaX = Math.trunc(input.deltaColumns);
|
||||
const deltaY = Math.trunc(input.deltaRows);
|
||||
const resizeWest = direction.includes("w");
|
||||
const resizeEast = direction.includes("e");
|
||||
const resizeNorth = direction.includes("n");
|
||||
const resizeSouth = direction.includes("s");
|
||||
|
||||
let x = placement.x;
|
||||
let y = placement.y;
|
||||
let width = placement.w;
|
||||
let height = placement.h;
|
||||
|
||||
if (resizeWest) {
|
||||
const fixedRight = placement.x + placement.w;
|
||||
const maximumWidth = Math.min(constraints.maxWidth, fixedRight);
|
||||
if (constraints.minWidth > maximumWidth) return null;
|
||||
width = clamp(placement.w - deltaX, constraints.minWidth, maximumWidth);
|
||||
x = fixedRight - width;
|
||||
} else if (resizeEast) {
|
||||
const maximumWidth = Math.min(constraints.maxWidth, grid.columnCount - placement.x);
|
||||
if (constraints.minWidth > maximumWidth) return null;
|
||||
width = clamp(placement.w + deltaX, constraints.minWidth, maximumWidth);
|
||||
}
|
||||
|
||||
if (resizeNorth) {
|
||||
const fixedBottom = placement.y + placement.h;
|
||||
const maximumHeight = Math.min(constraints.maxHeight, fixedBottom);
|
||||
if (constraints.minHeight > maximumHeight) return null;
|
||||
height = clamp(placement.h - deltaY, constraints.minHeight, maximumHeight);
|
||||
y = fixedBottom - height;
|
||||
} else if (resizeSouth) {
|
||||
const maximumHeight = Math.min(constraints.maxHeight, getMaximumRows(grid) - placement.y);
|
||||
if (constraints.minHeight > maximumHeight) return null;
|
||||
height = clamp(placement.h + deltaY, constraints.minHeight, maximumHeight);
|
||||
}
|
||||
|
||||
return { ...placement, x, y, w: width, h: height };
|
||||
};
|
||||
|
||||
const evaluateMoveGuards = <TPlacement extends GridPlacement>(
|
||||
guards: readonly GridMoveGuard<TPlacement>[],
|
||||
context: Parameters<GridMoveGuard<TPlacement>>[0],
|
||||
): GridOperationRejection | null => {
|
||||
for (const guard of guards) {
|
||||
const result = guard(context);
|
||||
if (result === true) continue;
|
||||
if (result === false) return { code: "guard-rejected" };
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isTargetWithinActiveSubtree = <TPlacement extends GridPlacement>(
|
||||
state: TransactionalGridState<TPlacement>,
|
||||
targetGridId: string,
|
||||
activeId: string,
|
||||
) => {
|
||||
let current: TransactionalGrid<TPlacement> | undefined = findGrid(state, targetGridId);
|
||||
const visited = new Set<string>();
|
||||
while (current) {
|
||||
if (current.ownerPlacementId === activeId) return true;
|
||||
if (!current.parentGridId || visited.has(current.id)) return false;
|
||||
visited.add(current.id);
|
||||
current = findGrid(state, current.parentGridId);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const accepted = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
preview: TransactionalGridState<TPlacement>,
|
||||
): GridPreviewResult<TPlacement> => ({
|
||||
accepted: true,
|
||||
transaction: { ...transaction, preview },
|
||||
});
|
||||
|
||||
const rejected = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
rejection: GridOperationRejection,
|
||||
): GridPreviewResult<TPlacement> => ({ accepted: false, transaction, rejection });
|
||||
|
||||
const fitsRowLimits = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>) =>
|
||||
state.grids.every((grid) => {
|
||||
if (grid.maxRowCount === undefined || grid.maxRowCount === null) return true;
|
||||
return getLayoutRowCount(grid.placements) <= grid.maxRowCount;
|
||||
});
|
||||
|
||||
const getMaximumRows = <TPlacement extends GridPlacement>(grid: TransactionalGrid<TPlacement>) =>
|
||||
grid.maxRowCount ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const cloneGridState = <TPlacement extends GridPlacement>(
|
||||
state: TransactionalGridState<TPlacement>,
|
||||
): TransactionalGridState<TPlacement> => ({
|
||||
grids: state.grids.map((grid) => ({
|
||||
...grid,
|
||||
placements: grid.placements.map((placement) => ({ ...placement })),
|
||||
})),
|
||||
});
|
||||
|
||||
const findGrid = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>, gridId: string) =>
|
||||
state.grids.find((grid) => grid.id === gridId);
|
||||
|
||||
const getGrid = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>, gridId: string) => {
|
||||
const grid = findGrid(state, gridId);
|
||||
if (!grid) throw new Error(`Grid "${gridId}" does not exist`);
|
||||
return grid;
|
||||
};
|
||||
|
||||
const getPlacement = <TPlacement extends GridPlacement>(grid: TransactionalGrid<TPlacement>, itemId: string) => {
|
||||
const placement = grid.placements.find((candidate) => candidate.id === itemId);
|
||||
if (!placement) throw new Error(`Grid item "${itemId}" does not exist in grid "${grid.id}"`);
|
||||
return placement;
|
||||
};
|
||||
|
||||
const findPlacement = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>, itemId: string) => {
|
||||
for (const grid of state.grids) {
|
||||
const placement = grid.placements.find((candidate) => candidate.id === itemId);
|
||||
if (placement) return { grid, placement };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const areCoordinatesEqual = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
|
||||
|
||||
const clamp = (value: number, minimum: number, maximum: number) => Math.min(Math.max(value, minimum), maximum);
|
||||
|
||||
const toPositiveInteger = (value: number, name: string) => {
|
||||
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
|
||||
throw new RangeError(`${name} must be a positive integer`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const validateGridState = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>) => {
|
||||
const gridIds = new Set<string>();
|
||||
const placementIds = new Set<string>();
|
||||
const ownerIds = new Set<string>();
|
||||
|
||||
for (const grid of state.grids) {
|
||||
if (gridIds.has(grid.id)) throw new Error(`Duplicate grid id "${grid.id}"`);
|
||||
gridIds.add(grid.id);
|
||||
toPositiveInteger(grid.columnCount, `columnCount for grid "${grid.id}"`);
|
||||
if (grid.maxRowCount !== undefined && grid.maxRowCount !== null) {
|
||||
toPositiveInteger(grid.maxRowCount, `maxRowCount for grid "${grid.id}"`);
|
||||
}
|
||||
|
||||
for (const placement of grid.placements) {
|
||||
if (placementIds.has(placement.id)) throw new Error(`Duplicate grid item id "${placement.id}"`);
|
||||
placementIds.add(placement.id);
|
||||
validatePlacementBounds(placement, grid);
|
||||
}
|
||||
for (let index = 0; index < grid.placements.length; index += 1) {
|
||||
const placement = grid.placements[index];
|
||||
if (!placement) continue;
|
||||
for (const other of grid.placements.slice(index + 1)) {
|
||||
if (doGridPlacementsOverlap(placement, other)) {
|
||||
throw new Error(`Grid items "${placement.id}" and "${other.id}" overlap in grid "${grid.id}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const grid of state.grids) {
|
||||
if (grid.parentGridId && !gridIds.has(grid.parentGridId)) {
|
||||
throw new Error(`Parent grid "${grid.parentGridId}" for grid "${grid.id}" does not exist`);
|
||||
}
|
||||
if (grid.ownerPlacementId) {
|
||||
if (!grid.parentGridId) throw new Error(`Owned grid "${grid.id}" must have a parent grid`);
|
||||
if (ownerIds.has(grid.ownerPlacementId)) {
|
||||
throw new Error(`Grid item "${grid.ownerPlacementId}" owns more than one grid`);
|
||||
}
|
||||
ownerIds.add(grid.ownerPlacementId);
|
||||
const parent = getGrid(state, grid.parentGridId);
|
||||
if (!parent.placements.some((placement) => placement.id === grid.ownerPlacementId)) {
|
||||
throw new Error(`Owner item "${grid.ownerPlacementId}" does not exist in parent grid "${grid.parentGridId}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const grid of state.grids) {
|
||||
const visited = new Set<string>();
|
||||
let current: TransactionalGrid<TPlacement> | undefined = grid;
|
||||
while (current?.parentGridId) {
|
||||
if (visited.has(current.id)) throw new Error(`Grid hierarchy contains a cycle at "${current.id}"`);
|
||||
visited.add(current.id);
|
||||
current = getGrid(state, current.parentGridId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const validatePlacementBounds = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
) => {
|
||||
const values = [placement.x, placement.y, placement.w, placement.h];
|
||||
if (values.some((value) => !Number.isFinite(value) || !Number.isInteger(value))) {
|
||||
throw new Error(`Grid item "${placement.id}" must use finite integer coordinates`);
|
||||
}
|
||||
if (placement.x < 0 || placement.y < 0 || placement.w < 1 || placement.h < 1) {
|
||||
throw new Error(`Grid item "${placement.id}" has invalid bounds`);
|
||||
}
|
||||
if (placement.x + placement.w > grid.columnCount) {
|
||||
throw new Error(`Grid item "${placement.id}" exceeds grid "${grid.id}" columns`);
|
||||
}
|
||||
if (placement.y + placement.h > getMaximumRows(grid)) {
|
||||
throw new Error(`Grid item "${placement.id}" exceeds grid "${grid.id}" rows`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
export {
|
||||
beginGridTransaction,
|
||||
cancelGridTransaction,
|
||||
commitGridTransaction,
|
||||
previewGridMove,
|
||||
previewGridResize,
|
||||
resolvePinnedGridCollisions,
|
||||
} from "./engine";
|
||||
export { getPointerProjectedShape, getSnappedGridCoordinates, getSnappedGridDelta } from "./coordinates";
|
||||
export type { DragProjectionOrigin } from "./coordinates";
|
||||
export type {
|
||||
BeginGridTransactionInput,
|
||||
GridMoveGuard,
|
||||
GridMoveGuardContext,
|
||||
GridMoveGuardResult,
|
||||
GridMoveInput,
|
||||
GridMoveOptions,
|
||||
GridOperationRejection,
|
||||
GridPreviewResult,
|
||||
GridResizeDirection,
|
||||
GridResizeInput,
|
||||
GridTransaction,
|
||||
TransactionalGrid,
|
||||
TransactionalGridState,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
|
||||
/** A positioned grid with optional ownership metadata for nested containers. */
|
||||
export interface TransactionalGrid<TPlacement extends GridPlacement = GridPlacement> {
|
||||
id: string;
|
||||
columnCount: number;
|
||||
maxRowCount?: number | null;
|
||||
/** The grid containing the placement that owns this nested grid. */
|
||||
parentGridId?: string | null;
|
||||
/** The container placement whose content is rendered by this grid. */
|
||||
ownerPlacementId?: string | null;
|
||||
placements: readonly TPlacement[];
|
||||
}
|
||||
|
||||
/** The complete layout that must change atomically for cross-grid operations. */
|
||||
export interface TransactionalGridState<TPlacement extends GridPlacement = GridPlacement> {
|
||||
grids: readonly TransactionalGrid<TPlacement>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag or resize transaction. Every preview is recomputed from `snapshot`,
|
||||
* never from the previous preview, so noisy drag-over events cannot accumulate
|
||||
* coordinate drift.
|
||||
*/
|
||||
export interface GridTransaction<TPlacement extends GridPlacement = GridPlacement> {
|
||||
activeId: string;
|
||||
sourceGridId: string;
|
||||
snapshot: TransactionalGridState<TPlacement>;
|
||||
preview: TransactionalGridState<TPlacement>;
|
||||
}
|
||||
|
||||
export interface BeginGridTransactionInput {
|
||||
activeId: string;
|
||||
/** Optional disambiguation and stale-event protection. */
|
||||
sourceGridId?: string;
|
||||
}
|
||||
|
||||
export interface GridMoveInput {
|
||||
targetGridId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type GridResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
|
||||
|
||||
export interface GridResizeInput {
|
||||
direction: GridResizeDirection;
|
||||
/** Signed displacement of the resize handle in grid columns. */
|
||||
deltaColumns: number;
|
||||
/** Signed displacement of the resize handle in grid rows. */
|
||||
deltaRows: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export interface GridOperationRejection {
|
||||
code:
|
||||
| "descendant-target"
|
||||
| "guard-rejected"
|
||||
| "invalid-coordinate"
|
||||
| "item-too-large"
|
||||
| "resize-constraints"
|
||||
| "row-limit"
|
||||
| "unknown-target-grid"
|
||||
| (string & {});
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface GridMoveGuardContext<TPlacement extends GridPlacement = GridPlacement> {
|
||||
active: TPlacement;
|
||||
sourceGrid: TransactionalGrid<TPlacement>;
|
||||
targetGrid: TransactionalGrid<TPlacement>;
|
||||
snapshot: TransactionalGridState<TPlacement>;
|
||||
/** True when the target is owned by the active container or one of its descendants. */
|
||||
targetIsDescendant: boolean;
|
||||
}
|
||||
|
||||
export type GridMoveGuardResult = boolean | GridOperationRejection;
|
||||
export type GridMoveGuard<TPlacement extends GridPlacement = GridPlacement> = (
|
||||
context: GridMoveGuardContext<TPlacement>,
|
||||
) => GridMoveGuardResult;
|
||||
|
||||
export interface GridMoveOptions<TPlacement extends GridPlacement = GridPlacement> {
|
||||
guards?: readonly GridMoveGuard<TPlacement>[];
|
||||
}
|
||||
|
||||
export type GridPreviewResult<TPlacement extends GridPlacement = GridPlacement> =
|
||||
| {
|
||||
accepted: true;
|
||||
transaction: GridTransaction<TPlacement>;
|
||||
}
|
||||
| {
|
||||
accepted: false;
|
||||
transaction: GridTransaction<TPlacement>;
|
||||
rejection: GridOperationRejection;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEvent, KeyboardEvent, PropsWithChildren } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
@@ -9,26 +9,31 @@ import { useCurrentLayout } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import {
|
||||
getEditableGridCellAttributes,
|
||||
getLayoutRowCount,
|
||||
getLogicalItemStyle,
|
||||
getReadonlyGridItemAttributes,
|
||||
placeGridItem,
|
||||
} from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import classes from "./section-grid.module.css";
|
||||
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd";
|
||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "./use-grid-layout-actions";
|
||||
import classes from "./section-grid.module.css";
|
||||
|
||||
interface FixedGridItemProps {
|
||||
item: DynamicSectionItem | SectionItem;
|
||||
item: ContainerSectionItem | SectionItem;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static SSR geometry in view mode and the stable content/keyboard handle in
|
||||
* edit mode. The focused tile owns grid-based keyboard movement while dnd-kit
|
||||
* handles pointer and touch input on the outer editor shell.
|
||||
*/
|
||||
export const FixedGridItem = ({
|
||||
item,
|
||||
minWidth = 1,
|
||||
@@ -36,22 +41,35 @@ export const FixedGridItem = ({
|
||||
children,
|
||||
}: PropsWithChildren<FixedGridItemProps>) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const runtimeStatus = useGridEditorRuntimeStatus();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const t = useI18n();
|
||||
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
|
||||
const { commitSectionGrid } = useGridLayoutActions();
|
||||
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
|
||||
const instructionsId = useId();
|
||||
const isEditorActive = isEditMode && runtimeStatus === "ready";
|
||||
const placement = toPlacement(item);
|
||||
const renderedPlacements = [...items, ...innerSections].map(toPlacement);
|
||||
const keyboardGridRef = useRef<{
|
||||
source: readonly SectionGridPlacement[];
|
||||
current: readonly SectionGridPlacement[];
|
||||
}>({
|
||||
source: renderedPlacements,
|
||||
current: renderedPlacements,
|
||||
});
|
||||
const displayName =
|
||||
item.type === "item"
|
||||
? item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`)
|
||||
: item.options.title.trim() || t("section.dynamic.untitled");
|
||||
: item.options.title.trim() || t("section.container.untitled");
|
||||
const accessibleLabel = t("item.moveResize.entryLabel", {
|
||||
name: displayName,
|
||||
column: String(placement.x + 1),
|
||||
row: String(placement.y + 1),
|
||||
});
|
||||
const semantics = isEditorActive
|
||||
? getEditableGridCellAttributes({ label: accessibleLabel, placement })
|
||||
: getReadonlyGridItemAttributes(placement);
|
||||
|
||||
const setKeyboardEditing = (next: boolean) => {
|
||||
setIsKeyboardEditing(next);
|
||||
@@ -61,7 +79,7 @@ export const FixedGridItem = ({
|
||||
};
|
||||
|
||||
const handleKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget || !isEditMode || event.altKey || event.ctrlKey || event.metaKey) {
|
||||
if (event.target !== event.currentTarget || !isEditorActive || event.altKey || event.ctrlKey || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
@@ -77,27 +95,60 @@ export const FixedGridItem = ({
|
||||
if (!isKeyboardEditing || !isArrowKey(event.key)) return;
|
||||
|
||||
event.preventDefault();
|
||||
const allPlacements = [...items, ...innerSections].map(toPlacement);
|
||||
const current = allPlacements.find((candidate) => candidate.id === item.id);
|
||||
if (document.body.hasAttribute("data-board-grid-interacting")) {
|
||||
setKeyboardEditing(false);
|
||||
return;
|
||||
}
|
||||
if (!arePlacementListsEqual(keyboardGridRef.current.source, renderedPlacements)) {
|
||||
keyboardGridRef.current = {
|
||||
source: renderedPlacements,
|
||||
current: renderedPlacements,
|
||||
};
|
||||
}
|
||||
const placements = keyboardGridRef.current.current;
|
||||
const current = placements.find((candidate) => candidate.id === item.id);
|
||||
if (!current) return;
|
||||
|
||||
const next = getKeyboardPlacement(current, event.key, event.shiftKey, {
|
||||
columnCount,
|
||||
minWidth,
|
||||
minHeight,
|
||||
maxRowCount,
|
||||
});
|
||||
if (next.x === current.x && next.y === current.y && next.w === current.w && next.h === current.h) {
|
||||
const transaction = beginGridTransaction(
|
||||
{
|
||||
grids: [
|
||||
{
|
||||
id: section.id,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
placements,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ activeId: current.id, sourceGridId: section.id },
|
||||
);
|
||||
const delta = getKeyboardDelta(event.key);
|
||||
const result = event.shiftKey
|
||||
? previewGridResize(transaction, {
|
||||
direction: "se",
|
||||
deltaColumns: delta.x,
|
||||
deltaRows: delta.y,
|
||||
minWidth,
|
||||
minHeight,
|
||||
})
|
||||
: previewGridMove(transaction, {
|
||||
targetGridId: section.id,
|
||||
x: current.x + delta.x,
|
||||
y: current.y + delta.y,
|
||||
});
|
||||
const next = result.transaction.preview.grids[0]?.placements.find((candidate) => candidate.id === item.id);
|
||||
if (!result.accepted || !next || arePlacementsEqual(current, next)) {
|
||||
announce(`${accessibleLabel}: ${t("item.moveResize.keyboard.boundary")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = placeGridItem(allPlacements, next, columnCount) as SectionGridPlacement[];
|
||||
if (maxRowCount !== null && getLayoutRowCount(resolved) > maxRowCount) {
|
||||
announce(`${accessibleLabel}: ${t("item.moveResize.keyboard.boundary")}`);
|
||||
return;
|
||||
}
|
||||
commitSectionGrid({ layoutId: currentLayoutId, sectionId: section.id, placements: resolved });
|
||||
const committed = commitGridTransaction(result.transaction).grids[0];
|
||||
if (!committed) return;
|
||||
keyboardGridRef.current = {
|
||||
...keyboardGridRef.current,
|
||||
current: committed.placements,
|
||||
};
|
||||
commitSectionGrid({ layoutId: currentLayoutId, sectionId: section.id, placements: committed.placements });
|
||||
announce(
|
||||
`${displayName}: ${t("item.moveResize.field.xOffset.label")} ${next.x + 1}, ${t(
|
||||
"item.moveResize.field.yOffset.label",
|
||||
@@ -108,38 +159,36 @@ export const FixedGridItem = ({
|
||||
};
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
|
||||
if (isKeyboardEditing && !event.currentTarget.contains(event.relatedTarget)) {
|
||||
setIsKeyboardEditing(false);
|
||||
if (isKeyboardEditing && event.target === event.currentTarget) {
|
||||
setKeyboardEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const semantics = isEditMode
|
||||
? getEditableGridCellAttributes({ label: accessibleLabel, placement })
|
||||
: getReadonlyGridItemAttributes(placement);
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...semantics}
|
||||
className={combineClasses(isEditMode ? classes.editorEntry : classes.staticItem)}
|
||||
style={isEditMode ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
|
||||
data-grid-item-id={isEditMode ? undefined : item.id}
|
||||
data-grid-item-type={isEditMode ? undefined : item.type}
|
||||
data-editor-grid-entry={isEditMode ? "true" : undefined}
|
||||
className={combineClasses(isEditorActive ? classes.editorEntry : classes.staticItem)}
|
||||
style={isEditorActive ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
|
||||
data-grid-item-id={isEditorActive ? undefined : item.id}
|
||||
data-grid-item-type={isEditorActive ? undefined : item.type}
|
||||
data-editor-grid-entry={isEditorActive ? "true" : undefined}
|
||||
data-type={item.type}
|
||||
data-kind={item.kind}
|
||||
data-keyboard-editing={isEditMode ? String(isKeyboardEditing) : undefined}
|
||||
aria-describedby={isEditMode ? instructionsId : undefined}
|
||||
data-keyboard-editing={isEditorActive ? String(isKeyboardEditing) : undefined}
|
||||
aria-describedby={isEditorActive ? instructionsId : undefined}
|
||||
aria-keyshortcuts={
|
||||
isEditMode
|
||||
isEditorActive
|
||||
? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown"
|
||||
: undefined
|
||||
}
|
||||
onBlur={isEditMode ? handleBlur : undefined}
|
||||
onKeyDown={isEditMode ? handleKeyboard : undefined}
|
||||
onBlur={isEditorActive ? handleBlur : undefined}
|
||||
onKeyDown={isEditorActive ? handleKeyboard : undefined}
|
||||
>
|
||||
{isEditMode && <span className={classes.dragAffordance} data-testid="board-grid-drag-affordance" aria-hidden />}
|
||||
{isEditorActive && (
|
||||
<span className={classes.dragAffordance} data-testid="board-grid-drag-affordance" aria-hidden />
|
||||
)}
|
||||
{children}
|
||||
{isEditMode && (
|
||||
{isEditorActive && (
|
||||
<span id={instructionsId} className={classes.liveRegion}>
|
||||
{t("item.moveResize.keyboard.instructions")}
|
||||
</span>
|
||||
@@ -148,7 +197,7 @@ export const FixedGridItem = ({
|
||||
);
|
||||
};
|
||||
|
||||
const toPlacement = (item: DynamicSectionItem | SectionItem): SectionGridPlacement => ({
|
||||
const toPlacement = (item: ContainerSectionItem | SectionItem): SectionGridPlacement => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
x: item.xOffset,
|
||||
@@ -160,43 +209,17 @@ const toPlacement = (item: DynamicSectionItem | SectionItem): SectionGridPlaceme
|
||||
const isArrowKey = (key: string): key is "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" =>
|
||||
key === "ArrowLeft" || key === "ArrowRight" || key === "ArrowUp" || key === "ArrowDown";
|
||||
|
||||
const getKeyboardPlacement = (
|
||||
current: SectionGridPlacement,
|
||||
key: "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown",
|
||||
resize: boolean,
|
||||
{
|
||||
columnCount,
|
||||
minWidth,
|
||||
minHeight,
|
||||
maxRowCount,
|
||||
}: {
|
||||
columnCount: number;
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
maxRowCount: number | null;
|
||||
},
|
||||
): GridPlacement & Pick<SectionGridPlacement, "type"> => {
|
||||
if (resize) {
|
||||
const widthDelta = key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0;
|
||||
const heightDelta = key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0;
|
||||
return {
|
||||
...current,
|
||||
w: Math.max(minWidth, Math.min(columnCount - current.x, current.w + widthDelta)),
|
||||
h: Math.max(
|
||||
minHeight,
|
||||
Math.min(maxRowCount === null ? Number.MAX_SAFE_INTEGER : maxRowCount - current.y, current.h + heightDelta),
|
||||
),
|
||||
};
|
||||
}
|
||||
const getKeyboardDelta = (key: "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown") => ({
|
||||
x: key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0,
|
||||
y: key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0,
|
||||
});
|
||||
|
||||
const xDelta = key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0;
|
||||
const yDelta = key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0;
|
||||
return {
|
||||
...current,
|
||||
x: Math.max(0, Math.min(columnCount - current.w, current.x + xDelta)),
|
||||
y: Math.max(
|
||||
0,
|
||||
Math.min(maxRowCount === null ? Number.MAX_SAFE_INTEGER : maxRowCount - current.h, current.y + yDelta),
|
||||
),
|
||||
};
|
||||
};
|
||||
const arePlacementsEqual = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
|
||||
|
||||
const arePlacementListsEqual = (first: readonly GridPlacement[], second: readonly GridPlacement[]) =>
|
||||
first.length === second.length &&
|
||||
first.every((placement, index) => {
|
||||
const candidate = second[index];
|
||||
return candidate?.id === placement.id && arePlacementsEqual(placement, candidate);
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
|
||||
/**
|
||||
* GridStack's floating mode lets Homarr keep intentional empty rows, but it
|
||||
* disables GridStack's built-in swap path. Restore the intuitive subset: when
|
||||
* an item lands exactly in an equally-sized item's former slot, exchange the
|
||||
* two slots and leave every other item where it started.
|
||||
*/
|
||||
export const getDropSwapPlacements = (
|
||||
initial: readonly SectionGridPlacement[],
|
||||
current: readonly SectionGridPlacement[],
|
||||
movedItemId: string,
|
||||
bounds: { columnCount: number; rowCount?: number },
|
||||
): SectionGridPlacement[] | null => {
|
||||
const movedInitially = initial.find((placement) => placement.id === movedItemId);
|
||||
const movedCurrently = current.find((placement) => placement.id === movedItemId);
|
||||
if (!movedInitially || !movedCurrently) return null;
|
||||
if (
|
||||
movedInitially.x === movedCurrently.x &&
|
||||
movedInitially.y === movedCurrently.y &&
|
||||
movedInitially.w === movedCurrently.w &&
|
||||
movedInitially.h === movedCurrently.h
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displaced = initial.find(
|
||||
(placement) => placement.id !== movedItemId && placement.x === movedCurrently.x && placement.y === movedCurrently.y,
|
||||
);
|
||||
if (!displaced) return null;
|
||||
|
||||
const swapped = initial.map((placement) => {
|
||||
if (placement.id === movedItemId) {
|
||||
return {
|
||||
...placement,
|
||||
x: displaced.x,
|
||||
y: displaced.y,
|
||||
};
|
||||
}
|
||||
if (placement.id === displaced.id) {
|
||||
return {
|
||||
...placement,
|
||||
x: movedInitially.x,
|
||||
y: movedInitially.y,
|
||||
};
|
||||
}
|
||||
return placement;
|
||||
});
|
||||
|
||||
return placementsFit(swapped, bounds) ? swapped : null;
|
||||
};
|
||||
|
||||
const placementsFit = (
|
||||
placements: readonly SectionGridPlacement[],
|
||||
{ columnCount, rowCount }: { columnCount: number; rowCount?: number },
|
||||
) =>
|
||||
placements.every(
|
||||
(placement) =>
|
||||
placement.x >= 0 &&
|
||||
placement.y >= 0 &&
|
||||
placement.x + placement.w <= columnCount &&
|
||||
(rowCount === undefined || placement.y + placement.h <= rowCount),
|
||||
) &&
|
||||
placements.every((placement, index) =>
|
||||
placements.slice(index + 1).every((other) => !placementsOverlap(placement, other)),
|
||||
);
|
||||
|
||||
const placementsOverlap = (first: SectionGridPlacement, second: SectionGridPlacement) =>
|
||||
first.x < second.x + second.w &&
|
||||
first.x + first.w > second.x &&
|
||||
first.y < second.y + second.h &&
|
||||
first.y + first.h > second.y;
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export type GridEditorRuntimeStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
export interface GridResizePreview {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
const GridEditorRuntimeContext = createContext<GridEditorRuntimeStatus>("idle");
|
||||
const GridResizePreviewContext = createContext<GridResizePreview | null>(null);
|
||||
|
||||
export const GridEditorRuntimeProvider = ({
|
||||
children,
|
||||
status,
|
||||
}: PropsWithChildren<{ status: GridEditorRuntimeStatus }>) => (
|
||||
<GridEditorRuntimeContext.Provider value={status}>{children}</GridEditorRuntimeContext.Provider>
|
||||
);
|
||||
|
||||
export const useGridEditorRuntimeStatus = () => useContext(GridEditorRuntimeContext);
|
||||
|
||||
export const GridResizePreviewProvider = ({
|
||||
children,
|
||||
value,
|
||||
}: PropsWithChildren<{ value: GridResizePreview | null }>) => (
|
||||
<GridResizePreviewContext.Provider value={value}>{children}</GridResizePreviewContext.Provider>
|
||||
);
|
||||
|
||||
export const useGridResizePreview = (entryId: string) => {
|
||||
const preview = useContext(GridResizePreviewContext);
|
||||
return preview?.id === entryId ? preview : null;
|
||||
};
|
||||
@@ -1,130 +1,256 @@
|
||||
.grid-stack.grid-stack-editing > .grid-stack-placeholder > .placeholder-content {
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
|
||||
.board-grid-editor {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
overflow: visible;
|
||||
background-image:
|
||||
linear-gradient(to right, color-mix(in srgb, var(--mantine-color-dimmed) 18%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, color-mix(in srgb, var(--mantine-color-dimmed) 18%, transparent) 1px, transparent 1px);
|
||||
background-size: 224px 224px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.board-grid-entry {
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
transition:
|
||||
left 100ms ease,
|
||||
top 100ms ease,
|
||||
width 100ms ease,
|
||||
height 100ms ease;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-dnd-active="true"],
|
||||
.board-grid-entry[data-dnd-drag-source="true"] {
|
||||
z-index: 1000;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-dnd-drag-source="true"] {
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.board-grid-drag-overlay[data-dnd-dragging] {
|
||||
position: fixed !important;
|
||||
z-index: 10000;
|
||||
top: var(--dnd-top, 0) !important;
|
||||
left: var(--dnd-left, 0) !important;
|
||||
width: var(--dnd-width, auto);
|
||||
max-width: var(--dnd-width, auto);
|
||||
height: var(--dnd-height, auto);
|
||||
max-height: var(--dnd-height, auto);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
color: inherit;
|
||||
pointer-events: none !important;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
transform: var(--dnd-transform, none) !important;
|
||||
transition: var(--dnd-transition) !important;
|
||||
will-change: translate;
|
||||
}
|
||||
|
||||
.board-grid-drag-overlay[data-dnd-dragging]:not([data-dnd-dropping]) {
|
||||
translate: var(--dnd-translate) !important;
|
||||
}
|
||||
|
||||
.board-grid-drag-overlay[data-dnd-dragging][style*="--dnd-scale"] {
|
||||
scale: var(--dnd-scale) !important;
|
||||
transform-origin: var(--dnd-transform-origin) !important;
|
||||
}
|
||||
|
||||
.board-grid-drag-overlay-card {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
color: var(--mantine-color-text);
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 92%, transparent);
|
||||
border: 2px solid var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
box-shadow: var(--mantine-shadow-lg);
|
||||
zoom: var(--board-grid-overlay-content-zoom, 1);
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting] .board-grid-entry,
|
||||
body[data-board-grid-interacting] .board-grid-placeholder {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.board-grid-content-mount,
|
||||
.board-grid-item-content,
|
||||
.board-grid-item-content > [data-editor-grid-entry] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.board-grid-placeholder {
|
||||
z-index: 0;
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
|
||||
border: 2px dashed var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item.ui-draggable-dragging {
|
||||
filter: drop-shadow(var(--mantine-shadow-lg));
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .grid-stack-item-content {
|
||||
border-radius: var(--mantine-radius-md);
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: 2px;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
outline-color 160ms ease,
|
||||
background-color 160ms ease;
|
||||
left 100ms ease,
|
||||
top 100ms ease,
|
||||
width 100ms ease,
|
||||
height 100ms ease;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item:hover > .grid-stack-item-content,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item:focus-within > .grid-stack-item-content {
|
||||
outline-color: color-mix(in srgb, var(--mantine-primary-color-filled) 72%, transparent);
|
||||
.board-grid-editor[data-dnd-drop-target="true"] {
|
||||
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item.ui-draggable-dragging > .grid-stack-item-content,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item.ui-resizable-resizing > .grid-stack-item-content {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 3px;
|
||||
.board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
|
||||
outline-color: var(--mantine-color-red-filled);
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-handle {
|
||||
.board-grid-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
display: block;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w {
|
||||
width: 28px;
|
||||
.board-grid-resize-handle[data-grid-resize-disabled="true"] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s {
|
||||
height: 28px;
|
||||
.board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"] {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: var(--mantine-color-dimmed);
|
||||
background-image: none;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
transform: none;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw::after {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
content: "";
|
||||
border-bottom: 2px solid currentColor;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se::after {
|
||||
right: 8px;
|
||||
border-right: 2px solid currentColor;
|
||||
border-radius: 0 0 3px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw::after {
|
||||
left: 8px;
|
||||
border-left: 2px solid currentColor;
|
||||
border-radius: 0 0 0 3px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se:hover,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw:hover {
|
||||
color: var(--mantine-primary-color-filled);
|
||||
background-color: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
background-color: var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after {
|
||||
top: 25%;
|
||||
bottom: 25%;
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after {
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after {
|
||||
left: 5px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s::after {
|
||||
right: 25%;
|
||||
bottom: 5px;
|
||||
left: 25%;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e:hover::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w:hover::after,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s:hover::after {
|
||||
.board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"]
|
||||
> .board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.grid-stack.grid-stack-editing,
|
||||
.grid-stack.grid-stack-editing .grid-stack-item,
|
||||
.grid-stack.grid-stack-editing .grid-stack-item-content,
|
||||
.grid-stack.grid-stack-editing .ui-resizable-handle::after {
|
||||
transition: none !important;
|
||||
.board-grid-resize-handle::after {
|
||||
position: absolute;
|
||||
inset: 50% auto auto 50%;
|
||||
width: 12px;
|
||||
height: 3px;
|
||||
content: "";
|
||||
background: color-mix(in srgb, var(--mantine-color-text) 70%, transparent);
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.board-grid-entry:hover > .board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after,
|
||||
.board-grid-resize-handle:not([data-grid-resize-disabled="true"]):focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -8px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="n"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
right: 44px;
|
||||
left: 44px;
|
||||
height: 32px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="n"] {
|
||||
top: -12px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
bottom: -12px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
top: 44px;
|
||||
bottom: 44px;
|
||||
width: 32px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"] {
|
||||
right: -12px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
left: -12px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"]::after,
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"]::after {
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
z-index: 9;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
||||
top: -12px;
|
||||
right: -12px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"] {
|
||||
right: -12px;
|
||||
bottom: -12px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
|
||||
bottom: -12px;
|
||||
left: -12px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
top: -12px;
|
||||
left: -12px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"]::after,
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"]::after {
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"]::after,
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"]::after {
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.board-grid-entry,
|
||||
.board-grid-placeholder,
|
||||
.board-grid-resize-handle::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,40 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { GridItemHTMLElement } from "gridstack";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useIntegrations } from "@homarr/auth/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { SectionContentItem } from "../content";
|
||||
import { SectionProvider } from "../section-context";
|
||||
import { useSectionItems } from "../use-section-items";
|
||||
import { decorateGridResizeHandles } from "./grid-resize-handles";
|
||||
import { useGridResizePreview } from "./grid-editor-runtime";
|
||||
import classes from "./section-grid.module.css";
|
||||
|
||||
interface GridPortalHostContextValue {
|
||||
announce: (message: string) => void;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
containers: ReadonlyMap<string, HTMLElement>;
|
||||
acquireContainer: (id: string) => HTMLElement;
|
||||
releaseContainer: (id: string, container: HTMLElement) => void;
|
||||
}
|
||||
|
||||
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* One portal registry owns every edit-mode item on the board. Its content
|
||||
* containers are stable even when GridStack replaces or transfers the outer
|
||||
* positioning shell, so moving between grids does not remount widget content.
|
||||
* containers are stable even when drag-and-drop replaces or transfers the
|
||||
* outer positioning shell, so moving between grids does not remount widget
|
||||
* content.
|
||||
*/
|
||||
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const integrations = useIntegrations();
|
||||
const containersRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const [containers, setContainers] = useState<Map<string, HTMLElement>>(() => new Map());
|
||||
@@ -48,36 +46,49 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
if (existing) return existing;
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "grid-stack-item-content";
|
||||
container.style.overflow = "visible";
|
||||
container.className = "board-grid-item-content";
|
||||
container.dataset.boardGridPortal = id;
|
||||
containersRef.current.set(id, container);
|
||||
setContainers(new Map(containersRef.current));
|
||||
return container;
|
||||
}, []);
|
||||
|
||||
const releaseContainer = useCallback((id: string, container: HTMLElement) => {
|
||||
// Controlled cross-grid moves remove the source shell before the target
|
||||
// adopts this container. Wait until all grid effects in the commit ran.
|
||||
queueMicrotask(() => {
|
||||
if (container.isConnected || containersRef.current.get(id) !== container) return;
|
||||
containersRef.current.delete(id);
|
||||
setContainers(new Map(containersRef.current));
|
||||
});
|
||||
}, []);
|
||||
// Keep acquired nodes for the host's lifetime. Cross-grid commits can
|
||||
// transiently render neither shell, and detaching the node must not remount
|
||||
// live widget state before the destination shell adopts it.
|
||||
|
||||
const value = useMemo<GridPortalHostContextValue>(
|
||||
() => ({
|
||||
announce,
|
||||
integrations,
|
||||
containers,
|
||||
acquireContainer,
|
||||
releaseContainer,
|
||||
}),
|
||||
[acquireContainer, announce, integrations, releaseContainer],
|
||||
[acquireContainer, announce, containers, integrations],
|
||||
);
|
||||
|
||||
return (
|
||||
<GridPortalHostContext.Provider value={value}>
|
||||
{children}
|
||||
<div className={classes.liveRegion} aria-live="polite" aria-atomic="true">
|
||||
<span key={announcement.id}>{announcement.message}</span>
|
||||
</div>
|
||||
</GridPortalHostContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders stable portal content at this component's logical React position.
|
||||
* Keep it inside the editor runtime and dnd-kit providers so portaled entries
|
||||
* inherit the same keyboard, transaction, and nested-grid contexts as shells.
|
||||
*/
|
||||
export const BoardGridPortalRenderer = () => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { announce, containers, integrations } = useBoardGridPortalHost();
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from(containers, ([id, container]) => {
|
||||
const ownerSectionId = getOwnerSectionId(board, currentLayoutId, id);
|
||||
return ownerSectionId
|
||||
@@ -85,7 +96,6 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
<GridPortalContent
|
||||
itemId={id}
|
||||
ownerSectionId={ownerSectionId}
|
||||
container={container}
|
||||
integrations={integrations}
|
||||
announce={announce}
|
||||
/>,
|
||||
@@ -94,10 +104,7 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
)
|
||||
: null;
|
||||
})}
|
||||
<div className={classes.liveRegion} aria-live="polite" aria-atomic="true">
|
||||
<span key={announcement.id}>{announcement.message}</span>
|
||||
</div>
|
||||
</GridPortalHostContext.Provider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -110,40 +117,33 @@ export const useBoardGridPortalHost = () => {
|
||||
interface GridPortalContentProps {
|
||||
itemId: string;
|
||||
ownerSectionId: string;
|
||||
container: HTMLElement;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
announce: (message: string) => void;
|
||||
}
|
||||
|
||||
const GridPortalContent = ({ itemId, ownerSectionId, container, integrations, announce }: GridPortalContentProps) => {
|
||||
const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: GridPortalContentProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { items, innerSections } = useSectionItems(ownerSectionId);
|
||||
const entry = [...items, ...innerSections].find((candidate) => candidate.id === itemId);
|
||||
const entryPreview = useGridResizePreview(itemId);
|
||||
const ownerPreview = useGridResizePreview(ownerSectionId);
|
||||
const persistedEntry = [...items, ...innerSections].find((candidate) => candidate.id === itemId);
|
||||
const entry = withResizePreview(persistedEntry, entryPreview);
|
||||
const rawSection = board.sections.find((section) => section.id === ownerSectionId);
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
const section = toGridSection(rawSection, currentLayoutId);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const itemElement = container.parentElement as GridItemHTMLElement | null;
|
||||
if (!itemElement) return;
|
||||
|
||||
itemElement.gridstackNode?.grid?.refreshDragHandles(itemElement);
|
||||
decorateGridResizeHandles(itemElement, {
|
||||
minW: itemElement.gridstackNode?.minW,
|
||||
minH: itemElement.gridstackNode?.minH,
|
||||
});
|
||||
}, [container, ownerSectionId]);
|
||||
const persistedSection = toGridSection(rawSection, currentLayoutId);
|
||||
const section =
|
||||
persistedSection?.kind === "container" ? withResizePreview(persistedSection, ownerPreview) : persistedSection;
|
||||
|
||||
if (!entry || !section || !currentLayout) return null;
|
||||
|
||||
const columnCount =
|
||||
section.kind === "dynamic"
|
||||
section.kind === "container"
|
||||
? section.width
|
||||
: section.kind === "empty"
|
||||
? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
|
||||
: currentLayout.columnCount;
|
||||
const configuredMaxRowCount = section.kind === "dynamic" ? section.height : null;
|
||||
const configuredMaxRowCount = section.kind === "container" ? section.height : null;
|
||||
const contentRowCount = Math.max(
|
||||
1,
|
||||
...items.map((item) => item.yOffset + item.height),
|
||||
@@ -168,12 +168,28 @@ const GridPortalContent = ({ itemId, ownerSectionId, container, integrations, an
|
||||
);
|
||||
};
|
||||
|
||||
const withResizePreview = <
|
||||
TValue extends { xOffset: number; yOffset: number; width: number; height: number } | null | undefined,
|
||||
>(
|
||||
value: TValue,
|
||||
preview: { x: number; y: number; w: number; h: number } | null,
|
||||
): TValue =>
|
||||
value && preview
|
||||
? ({
|
||||
...value,
|
||||
xOffset: preview.x,
|
||||
yOffset: preview.y,
|
||||
width: preview.w,
|
||||
height: preview.h,
|
||||
} as TValue)
|
||||
: value;
|
||||
|
||||
const toGridSection = (
|
||||
section: Section | undefined,
|
||||
layoutId: string,
|
||||
): Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem | null => {
|
||||
): Exclude<Section, { kind: "container" }> | ContainerSectionItem | null => {
|
||||
if (!section) return null;
|
||||
if (section.kind !== "dynamic") return section;
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) return null;
|
||||
@@ -191,7 +207,7 @@ const getOwnerSectionId = (board: ReturnType<typeof useRequiredBoard>, layoutId:
|
||||
return item.layouts.find((layout) => layout.layoutId === layoutId)?.sectionId ?? null;
|
||||
}
|
||||
|
||||
const section = board.sections.find((candidate) => candidate.kind === "dynamic" && candidate.id === entryId);
|
||||
if (!section || section.kind !== "dynamic") return null;
|
||||
const section = board.sections.find((candidate) => candidate.kind === "container" && candidate.id === entryId);
|
||||
if (!section || section.kind !== "container") return null;
|
||||
return section.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId ?? null;
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import { LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
|
||||
interface GridResizeMinimum {
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
}
|
||||
|
||||
export const decorateGridResizeHandles = (element: HTMLElement, minimum: GridResizeMinimum = {}) => {
|
||||
element.style.minWidth = `${(minimum.minW ?? 1) * LOGICAL_GRID_PITCH}px`;
|
||||
element.style.minHeight = `${(minimum.minH ?? 1) * LOGICAL_GRID_PITCH}px`;
|
||||
|
||||
for (const handle of element.querySelectorAll<HTMLElement>(".ui-resizable-handle")) {
|
||||
const direction = Array.from(handle.classList).find((className) =>
|
||||
/^ui-resizable-(?:n|ne|e|se|s|sw|w|nw)$/.test(className),
|
||||
);
|
||||
if (!direction) continue;
|
||||
const directionName = direction.replace("ui-resizable-", "");
|
||||
|
||||
handle.setAttribute(
|
||||
"data-testid",
|
||||
directionName === "se" ? "board-grid-resize-handle" : `board-grid-resize-handle-${directionName}`,
|
||||
);
|
||||
handle.setAttribute("data-grid-resize-direction", directionName);
|
||||
handle.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
};
|
||||
@@ -14,10 +14,6 @@
|
||||
inset: 0 auto auto 0;
|
||||
}
|
||||
|
||||
.editorGrid:not(:global(.grid-stack-editing)) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.staticItem {
|
||||
overflow: visible;
|
||||
transition:
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import {
|
||||
getCollapsedDisplayLayout,
|
||||
getEditableCanvasAttributes,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
normalizeGridPlacement,
|
||||
} from "~/components/board/layout";
|
||||
import { loadGridEditorAsync } from "./grid-editor-loader";
|
||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
import { SectionContent } from "../content";
|
||||
import { useCollapsedSectionIds } from "../section-collapse";
|
||||
@@ -63,7 +64,7 @@ function GridEditorLoading() {
|
||||
}
|
||||
|
||||
interface SectionGridProps {
|
||||
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
|
||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
||||
columnCount: number;
|
||||
requestedRowCount?: number;
|
||||
label: string;
|
||||
@@ -80,6 +81,7 @@ export const SectionGrid = ({
|
||||
className,
|
||||
}: SectionGridProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const editorRuntimeStatus = useGridEditorRuntimeStatus();
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { items, innerSections } = useSectionItems(section.id);
|
||||
@@ -90,7 +92,7 @@ export const SectionGrid = ({
|
||||
new Map(
|
||||
innerSections.map((innerSection) => [
|
||||
innerSection.id,
|
||||
getDynamicSectionMinimumSize(board, currentLayoutId, innerSection.id),
|
||||
getContainerMinimumSize(board, currentLayoutId, innerSection.id),
|
||||
]),
|
||||
),
|
||||
[board, currentLayoutId, innerSections],
|
||||
@@ -144,7 +146,7 @@ export const SectionGrid = ({
|
||||
const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id)));
|
||||
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
|
||||
const rowCount = Math.max(contentRowCount, requestedRowCount);
|
||||
const fixedRowCount = section.kind === "dynamic";
|
||||
const fixedRowCount = section.kind === "container";
|
||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||
const logicalHeight = getLogicalTrackSize(rowCount);
|
||||
const canvasAttributes = isEditMode
|
||||
@@ -172,21 +174,24 @@ export const SectionGrid = ({
|
||||
data-section-kind={section.kind}
|
||||
data-rail-placement={railPlacement}
|
||||
>
|
||||
{isEditMode ? (
|
||||
{isEditMode && editorRuntimeStatus === "ready" ? (
|
||||
<GridEditor
|
||||
sectionId={section.id}
|
||||
columnCount={columnCount}
|
||||
rowCount={rowCount}
|
||||
fixedRowCount={fixedRowCount}
|
||||
placements={displayPlacements}
|
||||
className={combineClasses("grid-stack", classes.editorGrid)}
|
||||
className={combineClasses("board-grid-editor", classes.editorGrid)}
|
||||
/>
|
||||
) : isEditMode && editorRuntimeStatus === "loading" ? (
|
||||
<GridEditorLoading />
|
||||
) : (
|
||||
<Box
|
||||
className={classes.staticGrid}
|
||||
style={{ width: logicalWidth, height: logicalHeight }}
|
||||
data-grid-section-id={section.id}
|
||||
data-kind={section.kind}
|
||||
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
|
||||
>
|
||||
<SectionContent />
|
||||
</Box>
|
||||
@@ -196,19 +201,30 @@ export const SectionGrid = ({
|
||||
);
|
||||
};
|
||||
|
||||
const getDynamicSectionMinimumSize = (
|
||||
const getContainerMinimumSize = (
|
||||
board: ReturnType<typeof useRequiredBoard>,
|
||||
layoutId: string,
|
||||
sectionId: string,
|
||||
) => {
|
||||
const directItems = board.items.flatMap((item) => {
|
||||
visited = new Set<string>(),
|
||||
): { width: number; height: number } => {
|
||||
if (visited.has(sectionId)) return { width: 1, height: 1 };
|
||||
const nextVisited = new Set(visited).add(sectionId);
|
||||
const directItems: PlacementBounds[] = board.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout?.sectionId === sectionId ? [layout] : [];
|
||||
});
|
||||
const directSections = board.sections.flatMap((candidate) => {
|
||||
if (candidate.kind !== "dynamic") return [];
|
||||
const directSections: PlacementBounds[] = board.sections.flatMap((candidate) => {
|
||||
if (candidate.kind !== "container") return [];
|
||||
const layout = candidate.layouts.find((candidateLayout) => candidateLayout.layoutId === layoutId);
|
||||
return layout?.parentSectionId === sectionId ? [layout] : [];
|
||||
if (!layout || layout.parentSectionId !== sectionId) return [];
|
||||
const minimum = getContainerMinimumSize(board, layoutId, candidate.id, nextVisited);
|
||||
return [
|
||||
{
|
||||
...layout,
|
||||
width: Math.max(layout.width, minimum.width),
|
||||
height: Math.max(layout.height, minimum.height),
|
||||
},
|
||||
];
|
||||
});
|
||||
const children = [...directItems, ...directSections];
|
||||
|
||||
@@ -218,6 +234,13 @@ const getDynamicSectionMinimumSize = (
|
||||
};
|
||||
};
|
||||
|
||||
interface PlacementBounds {
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const withPlacement = <
|
||||
TItem extends {
|
||||
id: string;
|
||||
|
||||
@@ -59,6 +59,10 @@ vi.mock("../use-grid-layout-actions", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../grid-editor-runtime", () => ({
|
||||
useGridEditorRuntimeStatus: () => "ready",
|
||||
}));
|
||||
|
||||
vi.mock("../../section-context", () => ({
|
||||
useSectionContext: () => ({
|
||||
section: { id: "root", kind: "empty", options: {} },
|
||||
@@ -98,7 +102,7 @@ describe("fixed grid item behavior", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test("moves with the keyboard and deterministically pushes a collision down", () => {
|
||||
test("moves with the keyboard and swaps an equal-sized destination", () => {
|
||||
renderWeather(root);
|
||||
|
||||
const entry = getEditorEntry(container);
|
||||
@@ -110,7 +114,7 @@ describe("fixed grid item behavior", () => {
|
||||
sectionId: "root",
|
||||
placements: [
|
||||
{ id: "weather", type: "item", x: 1, y: 0, w: 1, h: 1 },
|
||||
{ id: "clock", type: "item", x: 1, y: 1, w: 1, h: 1 },
|
||||
{ id: "clock", type: "item", x: 0, y: 0, w: 1, h: 1 },
|
||||
],
|
||||
});
|
||||
expect(mocks.announce).toHaveBeenCalledTimes(2);
|
||||
@@ -140,6 +144,25 @@ describe("fixed grid item behavior", () => {
|
||||
],
|
||||
});
|
||||
|
||||
mocks.commitSectionGrid.mockReset();
|
||||
act(() =>
|
||||
entry.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "ArrowLeft",
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(mocks.commitSectionGrid).toHaveBeenCalledWith({
|
||||
layoutId: "layout",
|
||||
sectionId: "root",
|
||||
placements: [
|
||||
{ id: "weather", type: "item", x: 0, y: 0, w: 1, h: 1 },
|
||||
{ id: "clock", type: "item", x: 1, y: 1, w: 1, h: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
mocks.commitSectionGrid.mockReset();
|
||||
act(() =>
|
||||
entry.dispatchEvent(
|
||||
@@ -156,6 +179,27 @@ describe("fixed grid item behavior", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("applies repeated keyboard resizes before board props rerender", () => {
|
||||
renderWeather(root);
|
||||
|
||||
const entry = getEditorEntry(container);
|
||||
startKeyboardEditing(entry);
|
||||
act(() => {
|
||||
entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", shiftKey: true, bubbles: true }));
|
||||
entry.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", shiftKey: true, bubbles: true }));
|
||||
});
|
||||
|
||||
expect(mocks.commitSectionGrid).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.commitSectionGrid).toHaveBeenLastCalledWith({
|
||||
layoutId: "layout",
|
||||
sectionId: "root",
|
||||
placements: [
|
||||
{ id: "weather", type: "item", x: 0, y: 0, w: 1, h: 3 },
|
||||
{ id: "clock", type: "item", x: 1, y: 0, w: 1, h: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("uses the whole tile as the focusable move surface with a visual drag affordance", () => {
|
||||
getWeatherMock().advancedOptions.title = " Balcony forecast ";
|
||||
renderWeather(root);
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getDropSwapPlacements } from "../grid-drop-swap";
|
||||
import type { SectionGridPlacement } from "../use-grid-layout-actions";
|
||||
|
||||
const first: SectionGridPlacement = { id: "first", type: "item", x: 0, y: 0, w: 1, h: 1 };
|
||||
const second: SectionGridPlacement = { id: "second", type: "item", x: 1, y: 0, w: 1, h: 1 };
|
||||
const third: SectionGridPlacement = { id: "third", type: "item", x: 2, y: 0, w: 2, h: 1 };
|
||||
const initial = [first, second, third];
|
||||
const bounds = { columnCount: 4 };
|
||||
|
||||
describe("grid drop swapping", () => {
|
||||
test("swaps equally-sized items when one takes the other's exact slot", () => {
|
||||
const current: SectionGridPlacement[] = [{ ...first, x: 1 }, { ...second, y: 1 }, third];
|
||||
|
||||
expect(getDropSwapPlacements(initial, current, "first", bounds)).toEqual([
|
||||
{ ...first, x: 1 },
|
||||
{ ...second, x: 0 },
|
||||
third,
|
||||
]);
|
||||
});
|
||||
|
||||
test("swaps different-sized items when both destinations remain valid", () => {
|
||||
const wide: SectionGridPlacement = { id: "wide", type: "item", x: 0, y: 0, w: 2, h: 1 };
|
||||
const compact: SectionGridPlacement = { id: "compact", type: "item", x: 2, y: 0, w: 1, h: 1 };
|
||||
const current = [
|
||||
{ ...wide, x: 2 },
|
||||
{ ...compact, y: 1 },
|
||||
];
|
||||
|
||||
expect(getDropSwapPlacements([wide, compact], current, "wide", bounds)).toEqual([
|
||||
{ ...wide, x: 2 },
|
||||
{ ...compact, x: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps push behavior when a swap would overlap another item or exceed the section", () => {
|
||||
const differentSize: SectionGridPlacement[] = [{ ...first, x: 2 }, second, { ...third, y: 1 }];
|
||||
const partialPlacement: SectionGridPlacement[] = [{ ...third, x: 1 }, first, { ...second, y: 1 }];
|
||||
|
||||
expect(getDropSwapPlacements(initial, differentSize, "first", bounds)).toBeNull();
|
||||
expect(getDropSwapPlacements(initial, partialPlacement, "third", bounds)).toBeNull();
|
||||
expect(getDropSwapPlacements(initial, [{ ...third, x: 1 }, first, second], "third", { columnCount: 2 })).toBeNull();
|
||||
});
|
||||
|
||||
test("does nothing when the item did not move", () => {
|
||||
expect(getDropSwapPlacements(initial, initial, "first", bounds)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,10 +9,10 @@ const sectionGridPath = resolve(gridDirectory, "section-grid.tsx");
|
||||
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
|
||||
|
||||
describe("board grid import boundary", () => {
|
||||
test("keeps every GridStack runtime import inside the edit-only module", () => {
|
||||
test("keeps every current dnd-kit runtime import inside the edit-only module", () => {
|
||||
const runtimeImports = getSourceFiles(nextjsSourceDirectory)
|
||||
.flatMap(getRuntimeImports)
|
||||
.filter(({ specifier }) => specifier === "gridstack" || specifier.startsWith("gridstack/"))
|
||||
.filter(({ specifier }) => CURRENT_DND_KIT_PACKAGES.has(specifier))
|
||||
.map(({ filePath, kind, specifier }) => ({
|
||||
file: toSourceRelativePath(filePath),
|
||||
kind,
|
||||
@@ -28,12 +28,22 @@ describe("board grid import boundary", () => {
|
||||
{
|
||||
file: "components/board/sections/grid/grid-editor.tsx",
|
||||
kind: "static",
|
||||
specifier: "gridstack",
|
||||
specifier: "@dnd-kit/abstract",
|
||||
},
|
||||
{
|
||||
file: "components/board/sections/grid/grid-editor.tsx",
|
||||
kind: "static",
|
||||
specifier: "gridstack/dist/gridstack.min.css",
|
||||
specifier: "@dnd-kit/collision",
|
||||
},
|
||||
{
|
||||
file: "components/board/sections/grid/grid-editor.tsx",
|
||||
kind: "static",
|
||||
specifier: "@dnd-kit/dom",
|
||||
},
|
||||
{
|
||||
file: "components/board/sections/grid/grid-editor.tsx",
|
||||
kind: "static",
|
||||
specifier: "@dnd-kit/react",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -88,15 +98,14 @@ describe("board grid import boundary", () => {
|
||||
}
|
||||
|
||||
const editConditional = findAncestor(gridEditorUsage, ts.isConditionalExpression);
|
||||
expect(
|
||||
editConditional !== undefined &&
|
||||
ts.isIdentifier(editConditional.condition) &&
|
||||
editConditional.condition.text === "isEditMode" &&
|
||||
isDescendantOf(gridEditorUsage, editConditional.whenTrue),
|
||||
).toBe(true);
|
||||
expect(editConditional && isDescendantOf(gridEditorUsage, editConditional.whenTrue)).toBe(true);
|
||||
expect(editConditional?.condition.getText(sourceFile)).toContain("isEditMode");
|
||||
expect(editConditional?.condition.getText(sourceFile)).toContain('editorRuntimeStatus === "ready"');
|
||||
});
|
||||
});
|
||||
|
||||
const CURRENT_DND_KIT_PACKAGES = new Set(["@dnd-kit/abstract", "@dnd-kit/collision", "@dnd-kit/dom", "@dnd-kit/react"]);
|
||||
|
||||
interface RuntimeImport {
|
||||
filePath: string;
|
||||
kind: "dynamic" | "require" | "static";
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { LOGICAL_GRID_PITCH } from "../../../layout";
|
||||
import { decorateGridResizeHandles } from "../grid-resize-handles";
|
||||
|
||||
describe("grid resize handle decoration", () => {
|
||||
test("restores accessible hit-target markers after GridStack refreshes its handles", () => {
|
||||
const item = document.createElement("div");
|
||||
item.innerHTML = `
|
||||
<div class="ui-resizable-handle ui-resizable-e"></div>
|
||||
<div class="ui-resizable-handle ui-resizable-se"></div>
|
||||
`;
|
||||
|
||||
decorateGridResizeHandles(item, { minW: 2, minH: 3 });
|
||||
|
||||
const east = item.querySelector<HTMLElement>(".ui-resizable-e");
|
||||
const southeast = item.querySelector<HTMLElement>(".ui-resizable-se");
|
||||
expect(east?.dataset.testid).toBe("board-grid-resize-handle-e");
|
||||
expect(east?.dataset.gridResizeDirection).toBe("e");
|
||||
expect(east?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(southeast?.dataset.testid).toBe("board-grid-resize-handle");
|
||||
expect(southeast?.dataset.gridResizeDirection).toBe("se");
|
||||
expect(southeast?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(item.style.minWidth).toBe(`${2 * LOGICAL_GRID_PITCH}px`);
|
||||
expect(item.style.minHeight).toBe(`${3 * LOGICAL_GRID_PITCH}px`);
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,9 @@ export interface CommitSectionGridInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits a complete section snapshot in one cache update. GridStack can move
|
||||
* several neighbours for one gesture, so persisting only the active item would
|
||||
* lose its collision result.
|
||||
* Commits complete section snapshots in one cache update. One drag or resize
|
||||
* can move several neighbours and can cross section boundaries, so the whole
|
||||
* transaction must remain atomic.
|
||||
*/
|
||||
export const useGridLayoutActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
@@ -34,48 +34,68 @@ export const useGridLayoutActions = () => {
|
||||
),
|
||||
);
|
||||
updateBoard((previous) => {
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.map((item) => {
|
||||
const layouts = item.layouts.map((layout) => {
|
||||
const resolved = byId.get(`${layout.layoutId}:${item.id}`);
|
||||
if (!resolved || resolved.placement.type !== "item") return layout;
|
||||
const { placement, sectionId } = resolved;
|
||||
let hasChanges = false;
|
||||
const items = previous.items.map((item) => {
|
||||
const layouts = item.layouts.map((layout) => {
|
||||
const resolved = byId.get(`${layout.layoutId}:${item.id}`);
|
||||
if (!resolved || resolved.placement.type !== "item") return layout;
|
||||
const { placement, sectionId } = resolved;
|
||||
if (
|
||||
layout.sectionId === sectionId &&
|
||||
layout.xOffset === placement.x &&
|
||||
layout.yOffset === placement.y &&
|
||||
layout.width === placement.w &&
|
||||
layout.height === placement.h
|
||||
) {
|
||||
return layout;
|
||||
}
|
||||
|
||||
return {
|
||||
...layout,
|
||||
sectionId,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
};
|
||||
});
|
||||
return {
|
||||
...layout,
|
||||
sectionId,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
};
|
||||
});
|
||||
|
||||
return layouts.some((layout, index) => layout !== item.layouts[index]) ? { ...item, layouts } : item;
|
||||
}),
|
||||
sections: previous.sections.map((innerSection) => {
|
||||
if (innerSection.kind !== "dynamic") return innerSection;
|
||||
const layouts = innerSection.layouts.map((layout) => {
|
||||
const resolved = byId.get(`${layout.layoutId}:${innerSection.id}`);
|
||||
if (!resolved || resolved.placement.type !== "section") return layout;
|
||||
const { placement, sectionId } = resolved;
|
||||
if (!layouts.some((layout, index) => layout !== item.layouts[index])) return item;
|
||||
hasChanges = true;
|
||||
return { ...item, layouts };
|
||||
});
|
||||
const sections = previous.sections.map((innerSection) => {
|
||||
if (innerSection.kind !== "container") return innerSection;
|
||||
const layouts = innerSection.layouts.map((layout) => {
|
||||
const resolved = byId.get(`${layout.layoutId}:${innerSection.id}`);
|
||||
if (!resolved || resolved.placement.type !== "section") return layout;
|
||||
const { placement, sectionId } = resolved;
|
||||
if (
|
||||
layout.parentSectionId === sectionId &&
|
||||
layout.xOffset === placement.x &&
|
||||
layout.yOffset === placement.y &&
|
||||
layout.width === placement.w &&
|
||||
layout.height === placement.h
|
||||
) {
|
||||
return layout;
|
||||
}
|
||||
|
||||
return {
|
||||
...layout,
|
||||
parentSectionId: sectionId,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
};
|
||||
});
|
||||
return {
|
||||
...layout,
|
||||
parentSectionId: sectionId,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
};
|
||||
});
|
||||
|
||||
return layouts.some((layout, index) => layout !== innerSection.layouts[index])
|
||||
? { ...innerSection, layouts }
|
||||
: innerSection;
|
||||
}),
|
||||
};
|
||||
if (!layouts.some((layout, index) => layout !== innerSection.layouts[index])) return innerSection;
|
||||
hasChanges = true;
|
||||
return { ...innerSection, layouts };
|
||||
});
|
||||
|
||||
return hasChanges ? { ...previous, items, sections } : previous;
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
|
||||
@@ -29,7 +29,7 @@ export const useSectionActions = () => {
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return same section if section is not the one we're moving
|
||||
if (section.id !== innerSectionId) return section;
|
||||
if (section.kind !== "dynamic") return section;
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
@@ -57,7 +57,7 @@ export const useSectionActions = () => {
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return section without changes when not the section we're moving
|
||||
if (section.id !== innerSectionId) return section;
|
||||
if (section.kind !== "dynamic") return section;
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ import { createContext, useContext } from "react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
|
||||
import type { DynamicSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { ContainerSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
interface SectionContextProps {
|
||||
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
|
||||
innerSections: DynamicSectionItem[];
|
||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
||||
innerSections: ContainerSectionItem[];
|
||||
items: SectionItem[];
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
columnCount: number;
|
||||
|
||||
@@ -24,8 +24,8 @@ describe("section collapse storage", () => {
|
||||
test("uses the corrected fallback when no browser preference exists", () => {
|
||||
const storage = createStorage();
|
||||
|
||||
expect(readSectionCollapsedFromStorage(storage, "category", true)).toBe(true);
|
||||
expect(readSectionCollapsedFromStorage(storage, "dynamic", false)).toBe(false);
|
||||
expect(readSectionCollapsedFromStorage(storage, "first", true)).toBe(true);
|
||||
expect(readSectionCollapsedFromStorage(storage, "second", false)).toBe(false);
|
||||
});
|
||||
|
||||
test("writes only the versioned corrected state", () => {
|
||||
|
||||
@@ -3,40 +3,40 @@ import { describe, expect, test } from "vitest";
|
||||
import { getSectionItemsForLayout } from "../use-open-section-apps";
|
||||
|
||||
describe("getSectionItemsForLayout", () => {
|
||||
test("includes items in recursively nested dynamic sections", () => {
|
||||
test("includes items in recursively nested containers", () => {
|
||||
const board = {
|
||||
sections: [
|
||||
{ id: "category", kind: "category" },
|
||||
{ id: "root", kind: "empty" },
|
||||
{
|
||||
id: "first",
|
||||
kind: "dynamic",
|
||||
layouts: [{ layoutId: "desktop", parentSectionId: "category" }],
|
||||
kind: "container",
|
||||
layouts: [{ layoutId: "desktop", parentSectionId: "root" }],
|
||||
},
|
||||
{
|
||||
id: "second",
|
||||
kind: "dynamic",
|
||||
kind: "container",
|
||||
layouts: [{ layoutId: "desktop", parentSectionId: "first" }],
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{ id: "direct", layouts: [{ layoutId: "desktop", sectionId: "category" }] },
|
||||
{ id: "direct", layouts: [{ layoutId: "desktop", sectionId: "root" }] },
|
||||
{ id: "nested", layouts: [{ layoutId: "desktop", sectionId: "second" }] },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getSectionItemsForLayout(board, "category", "desktop").map((item) => item.id)).toEqual(["direct", "nested"]);
|
||||
expect(getSectionItemsForLayout(board, "root", "desktop").map((item) => item.id)).toEqual(["direct", "nested"]);
|
||||
});
|
||||
|
||||
test("uses containment from only the requested responsive layout", () => {
|
||||
const board = {
|
||||
sections: [
|
||||
{ id: "category", kind: "category" },
|
||||
{ id: "other", kind: "category" },
|
||||
{ id: "root", kind: "empty" },
|
||||
{ id: "other", kind: "empty" },
|
||||
{
|
||||
id: "dynamic",
|
||||
kind: "dynamic",
|
||||
id: "container",
|
||||
kind: "container",
|
||||
layouts: [
|
||||
{ layoutId: "mobile", parentSectionId: "category" },
|
||||
{ layoutId: "mobile", parentSectionId: "root" },
|
||||
{ layoutId: "desktop", parentSectionId: "other" },
|
||||
],
|
||||
},
|
||||
@@ -45,15 +45,15 @@ describe("getSectionItemsForLayout", () => {
|
||||
{
|
||||
id: "app",
|
||||
layouts: [
|
||||
{ layoutId: "mobile", sectionId: "dynamic" },
|
||||
{ layoutId: "desktop", sectionId: "dynamic" },
|
||||
{ layoutId: "mobile", sectionId: "container" },
|
||||
{ layoutId: "desktop", sectionId: "container" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getSectionItemsForLayout(board, "category", "mobile").map((item) => item.id)).toEqual(["app"]);
|
||||
expect(getSectionItemsForLayout(board, "category", "desktop")).toEqual([]);
|
||||
expect(getSectionItemsForLayout(board, "root", "mobile").map((item) => item.id)).toEqual(["app"]);
|
||||
expect(getSectionItemsForLayout(board, "root", "desktop")).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not loop when malformed section containment is cyclic", () => {
|
||||
@@ -61,12 +61,12 @@ describe("getSectionItemsForLayout", () => {
|
||||
sections: [
|
||||
{
|
||||
id: "root",
|
||||
kind: "dynamic",
|
||||
kind: "container",
|
||||
layouts: [{ layoutId: "desktop", parentSectionId: "child" }],
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
kind: "dynamic",
|
||||
kind: "container",
|
||||
layouts: [{ layoutId: "desktop", parentSectionId: "root" }],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { filterByItemKind } from "./category/filter";
|
||||
import { filterByItemKind } from "./filter-by-item-kind";
|
||||
|
||||
interface SectionItemLayout {
|
||||
layoutId: string;
|
||||
@@ -35,8 +35,8 @@ interface SectionTree<TItem extends SectionTreeItem> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds every item contained by a section in one responsive layout. Dynamic
|
||||
* sections can be nested, so their descendants are traversed recursively.
|
||||
* Finds every item contained by a section in one responsive layout. Containers
|
||||
* can be nested, so their descendants are traversed recursively.
|
||||
*/
|
||||
export const getSectionItemsForLayout = <TItem extends SectionTreeItem>(
|
||||
board: SectionTree<TItem>,
|
||||
@@ -46,7 +46,7 @@ export const getSectionItemsForLayout = <TItem extends SectionTreeItem>(
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
|
||||
for (const section of board.sections) {
|
||||
if (section.kind !== "dynamic") continue;
|
||||
if (section.kind !== "container") continue;
|
||||
|
||||
const layout = section.layouts?.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) continue;
|
||||
|
||||
@@ -2,16 +2,16 @@ import { useMemo } from "react";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const useSectionItems = (sectionId: string): { innerSections: DynamicSectionItem[]; items: SectionItem[] } => {
|
||||
export const useSectionItems = (sectionId: string): { innerSections: ContainerSectionItem[]; items: SectionItem[] } => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
|
||||
const innerSections = useMemo(
|
||||
() =>
|
||||
board.sections
|
||||
.filter((innerSection) => innerSection.kind === "dynamic")
|
||||
.filter((innerSection) => innerSection.kind === "container")
|
||||
.map(({ layouts, ...innerSection }) => {
|
||||
const layout = layouts.find((layout) => layout.layoutId === currentLayoutId);
|
||||
|
||||
|
||||
+1019
-120
File diff suppressed because it is too large.
Load diff
@@ -10,11 +10,12 @@ export const createHomarrContainer = (
|
||||
};
|
||||
} = {},
|
||||
) => {
|
||||
const image = process.env.HOMARR_E2E_IMAGE?.trim() || "homarr-e2e";
|
||||
if (!process.env.CI) {
|
||||
throw new Error("This test should only be run in CI or with a homarr image named 'homarr-e2e'");
|
||||
throw new Error(`This test should only be run in CI or with a local Homarr image (configured: '${image}')`);
|
||||
}
|
||||
|
||||
const container = new GenericContainer("homarr-e2e")
|
||||
const container = new GenericContainer(image)
|
||||
.withExposedPorts(7575)
|
||||
.withEnvironment({
|
||||
...options.environment,
|
||||
|
||||
@@ -917,18 +917,18 @@ export const boardRouter = createTRPCRouter({
|
||||
addedSections.map((section) => ({
|
||||
id: section.id,
|
||||
kind: section.kind,
|
||||
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
|
||||
xOffset: section.kind === "dynamic" ? null : section.xOffset,
|
||||
yOffset: section.kind === "empty" ? section.yOffset : null,
|
||||
xOffset: section.kind === "empty" ? section.xOffset : null,
|
||||
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
|
||||
name: "name" in section ? section.name : null,
|
||||
name: null,
|
||||
boardId: dbBoard.id,
|
||||
})),
|
||||
);
|
||||
|
||||
if (addedSections.some((section) => section.kind === "dynamic")) {
|
||||
if (addedSections.some((section) => section.kind === "container")) {
|
||||
await transaction.insert(schema.sectionLayouts).values(
|
||||
addedSections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.filter((section) => section.kind === "container")
|
||||
.flatMap((section) =>
|
||||
section.layouts.map(
|
||||
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
|
||||
@@ -1039,18 +1039,17 @@ export const boardRouter = createTRPCRouter({
|
||||
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
|
||||
|
||||
for (const section of updatedSections) {
|
||||
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
|
||||
await transaction
|
||||
.update(schema.sections)
|
||||
.set({
|
||||
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
|
||||
xOffset: prev?.kind !== "dynamic" && "xOffset" in section ? section.xOffset : null,
|
||||
yOffset: section.kind === "empty" ? section.yOffset : null,
|
||||
xOffset: section.kind === "empty" ? section.xOffset : null,
|
||||
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
|
||||
name: prev?.kind === "category" && "name" in section ? section.name : null,
|
||||
name: null,
|
||||
})
|
||||
.where(eq(schema.sections.id, section.id));
|
||||
|
||||
if (section.kind !== "dynamic") continue;
|
||||
if (section.kind !== "container") continue;
|
||||
|
||||
for (const sectionLayout of section.layouts) {
|
||||
await transaction
|
||||
@@ -1117,21 +1116,21 @@ export const boardRouter = createTRPCRouter({
|
||||
addedSections.map((section) => ({
|
||||
id: section.id,
|
||||
kind: section.kind,
|
||||
yOffset: section.kind !== "dynamic" ? section.yOffset : null,
|
||||
xOffset: section.kind === "dynamic" ? null : section.xOffset,
|
||||
yOffset: section.kind === "empty" ? section.yOffset : null,
|
||||
xOffset: section.kind === "empty" ? section.xOffset : null,
|
||||
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
|
||||
name: "name" in section ? section.name : null,
|
||||
name: null,
|
||||
boardId: dbBoard.id,
|
||||
})),
|
||||
)
|
||||
.run();
|
||||
|
||||
if (addedSections.some((section) => section.kind === "dynamic")) {
|
||||
if (addedSections.some((section) => section.kind === "container")) {
|
||||
transaction
|
||||
.insert(sectionLayouts)
|
||||
.values(
|
||||
addedSections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.filter((section) => section.kind === "container")
|
||||
.flatMap((section) =>
|
||||
section.layouts.map(
|
||||
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
|
||||
@@ -1249,19 +1248,18 @@ export const boardRouter = createTRPCRouter({
|
||||
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
|
||||
|
||||
for (const section of updatedSections) {
|
||||
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
|
||||
transaction
|
||||
.update(sections)
|
||||
.set({
|
||||
yOffset: prev?.kind !== "dynamic" && "yOffset" in section ? section.yOffset : null,
|
||||
xOffset: prev?.kind !== "dynamic" && "xOffset" in section ? section.xOffset : null,
|
||||
yOffset: section.kind === "empty" ? section.yOffset : null,
|
||||
xOffset: section.kind === "empty" ? section.xOffset : null,
|
||||
options: section.kind === "empty" ? emptySuperJSON : superjson.stringify(section.options),
|
||||
name: prev?.kind === "category" && "name" in section ? section.name : null,
|
||||
name: null,
|
||||
})
|
||||
.where(eq(sections.id, section.id))
|
||||
.run();
|
||||
|
||||
if (section.kind !== "dynamic") continue;
|
||||
if (section.kind !== "container") continue;
|
||||
|
||||
for (const sectionLayout of section.layouts) {
|
||||
transaction
|
||||
@@ -1772,7 +1770,7 @@ const getUpdatedBoardLayout = (
|
||||
});
|
||||
|
||||
const sectionLayoutsCollection = board.sections.flatMap((section): InferInsertModel<typeof sectionLayouts>[] => {
|
||||
if (section.kind !== "dynamic") return [];
|
||||
if (section.kind !== "container") return [];
|
||||
const currentElement = updatedElementById.get(section.id);
|
||||
if (!currentElement || currentElement.type !== "section") return [];
|
||||
|
||||
@@ -1815,7 +1813,7 @@ const getRootSectionForLane = (
|
||||
|
||||
const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>, layoutId: string) => {
|
||||
const sectionElements = board.sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.filter((section) => section.kind === "container")
|
||||
.map((section) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const clonedLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layoutId)!;
|
||||
@@ -1926,7 +1924,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
||||
parentSectionId: layout.parentSectionId,
|
||||
layoutId: layout.layoutId,
|
||||
})),
|
||||
collapsed: collapseStates.at(0)?.collapsed ?? section.kind === "category",
|
||||
collapsed: collapseStates.at(0)?.collapsed ?? false,
|
||||
}),
|
||||
),
|
||||
items: items
|
||||
|
||||
@@ -42,18 +42,18 @@ export const generateResponsiveGridFor = ({
|
||||
|
||||
const newItems: GridAlgorithmItem[] = [];
|
||||
|
||||
// Fix height of dynamic sections
|
||||
const dynamicSectionHeightMap = new Map<string, number>();
|
||||
const dynamicSectionsOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
|
||||
for (const dynamicSection of dynamicSectionsOfCurrentSection) {
|
||||
// A container must remain tall enough for its recursively reflowed contents.
|
||||
const containerHeightMap = new Map<string, number>();
|
||||
const containersOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
|
||||
for (const container of containersOfCurrentSection) {
|
||||
const result = generateResponsiveGridFor({
|
||||
items,
|
||||
previousWidth: dynamicSection.previousWidth,
|
||||
width: dynamicSection.width,
|
||||
sectionId: dynamicSection.id,
|
||||
previousWidth: container.previousWidth,
|
||||
width: container.width,
|
||||
sectionId: container.id,
|
||||
});
|
||||
newItems.push(...result.items);
|
||||
dynamicSectionHeightMap.set(dynamicSection.id, result.height);
|
||||
containerHeightMap.set(container.id, result.height);
|
||||
}
|
||||
|
||||
// Return same positions for items in the current section
|
||||
@@ -69,7 +69,7 @@ export const generateResponsiveGridFor = ({
|
||||
for (const item of normalizedItems) {
|
||||
const itemWithHeight = {
|
||||
...item,
|
||||
height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height,
|
||||
height: item.type === "section" ? Math.max(containerHeightMap.get(item.id) ?? 1, item.height) : item.height,
|
||||
};
|
||||
const position = nextFreeSpot(occupied2d, itemWithHeight, width);
|
||||
if (!position) throw new Error("No free spot available");
|
||||
|
||||
@@ -25,34 +25,34 @@ describe("Grid Algorithm", () => {
|
||||
|
||||
expect(output).toBe(item.output);
|
||||
});
|
||||
test.each(dynamicSectionTests)(
|
||||
"should convert a grid with dynamic sections from 16 columns to %i columns",
|
||||
test.each(containerTests)(
|
||||
"should convert a grid with nested containers from 16 columns to %i columns",
|
||||
(_, testInput) => {
|
||||
const outerDynamicSectionId = "b";
|
||||
const innerDynamicSectionId = "f";
|
||||
const outerContainerId = "b";
|
||||
const innerContainerId = "f";
|
||||
const items = [
|
||||
algoItem({ id: "a", width: 2, height: 2 }),
|
||||
algoItem({ id: outerDynamicSectionId, type: "section", width: 12, height: 3, yOffset: 2 }),
|
||||
algoItem({ id: "a", width: 2, sectionId: outerDynamicSectionId }),
|
||||
algoItem({ id: "b", width: 4, sectionId: outerDynamicSectionId, xOffset: 2 }),
|
||||
algoItem({ id: "c", width: 2, sectionId: outerDynamicSectionId, xOffset: 6 }),
|
||||
algoItem({ id: "d", width: 1, sectionId: outerDynamicSectionId, xOffset: 8 }),
|
||||
algoItem({ id: "e", width: 3, sectionId: outerDynamicSectionId, xOffset: 9 }),
|
||||
algoItem({ id: outerContainerId, type: "section", width: 12, height: 3, yOffset: 2 }),
|
||||
algoItem({ id: "a", width: 2, sectionId: outerContainerId }),
|
||||
algoItem({ id: "b", width: 4, sectionId: outerContainerId, xOffset: 2 }),
|
||||
algoItem({ id: "c", width: 2, sectionId: outerContainerId, xOffset: 6 }),
|
||||
algoItem({ id: "d", width: 1, sectionId: outerContainerId, xOffset: 8 }),
|
||||
algoItem({ id: "e", width: 3, sectionId: outerContainerId, xOffset: 9 }),
|
||||
algoItem({
|
||||
id: innerDynamicSectionId,
|
||||
id: innerContainerId,
|
||||
type: "section",
|
||||
width: 8,
|
||||
height: 2,
|
||||
yOffset: 1,
|
||||
sectionId: outerDynamicSectionId,
|
||||
sectionId: outerContainerId,
|
||||
}),
|
||||
algoItem({ id: "a", width: 2, sectionId: innerDynamicSectionId }),
|
||||
algoItem({ id: "b", width: 5, xOffset: 2, sectionId: innerDynamicSectionId }),
|
||||
algoItem({ id: "c", width: 1, height: 2, xOffset: 7, sectionId: innerDynamicSectionId }),
|
||||
algoItem({ id: "d", width: 7, yOffset: 1, sectionId: innerDynamicSectionId }),
|
||||
algoItem({ id: "g", width: 4, yOffset: 1, sectionId: outerDynamicSectionId, xOffset: 8 }),
|
||||
algoItem({ id: "h", width: 3, yOffset: 2, sectionId: outerDynamicSectionId, xOffset: 8 }),
|
||||
algoItem({ id: "i", width: 1, yOffset: 2, sectionId: outerDynamicSectionId, xOffset: 11 }),
|
||||
algoItem({ id: "a", width: 2, sectionId: innerContainerId }),
|
||||
algoItem({ id: "b", width: 5, xOffset: 2, sectionId: innerContainerId }),
|
||||
algoItem({ id: "c", width: 1, height: 2, xOffset: 7, sectionId: innerContainerId }),
|
||||
algoItem({ id: "d", width: 7, yOffset: 1, sectionId: innerContainerId }),
|
||||
algoItem({ id: "g", width: 4, yOffset: 1, sectionId: outerContainerId, xOffset: 8 }),
|
||||
algoItem({ id: "h", width: 3, yOffset: 2, sectionId: outerContainerId, xOffset: 8 }),
|
||||
algoItem({ id: "i", width: 1, yOffset: 2, sectionId: outerContainerId, xOffset: 11 }),
|
||||
algoItem({ id: "c", width: 5, yOffset: 5 }),
|
||||
];
|
||||
|
||||
@@ -64,10 +64,10 @@ describe("Grid Algorithm", () => {
|
||||
});
|
||||
|
||||
const rootItems = newItems.items.filter((item) => item.sectionId === ROOT_SECTION_ID);
|
||||
const outerSection = items.find((item) => item.id === outerDynamicSectionId);
|
||||
const outerItems = newItems.items.filter((item) => item.sectionId === outerDynamicSectionId);
|
||||
const innerSection = items.find((item) => item.id === innerDynamicSectionId);
|
||||
const innerItems = newItems.items.filter((item) => item.sectionId === innerDynamicSectionId);
|
||||
const outerSection = items.find((item) => item.id === outerContainerId);
|
||||
const outerItems = newItems.items.filter((item) => item.sectionId === outerContainerId);
|
||||
const innerSection = items.find((item) => item.id === innerContainerId);
|
||||
const innerItems = newItems.items.filter((item) => item.sectionId === innerContainerId);
|
||||
|
||||
expect(generateOutputText(rootItems, testInput.outputColumns)).toBe(testInput.root);
|
||||
expect(generateOutputText(outerItems, Math.min(testInput.outputColumns, outerSection?.width ?? 999))).toBe(
|
||||
@@ -192,7 +192,7 @@ const itemTests = [
|
||||
},
|
||||
].map((item) => [item.inputColumnCount, item.outputColumnCount, item] as const);
|
||||
|
||||
const dynamicSectionTests = [
|
||||
const containerTests = [
|
||||
{
|
||||
outputColumns: 16,
|
||||
root: `
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from "zod/v4";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { sectionCollapseStates, sections } from "@homarr/db/schema";
|
||||
import { emptySuperJSON } from "@homarr/definitions";
|
||||
import { categorySectionOptionsSchema, dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
|
||||
@@ -34,7 +34,7 @@ export const sectionRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (section.kind === "empty") {
|
||||
if (section.kind !== "container") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Section cannot be collapsed id=${input.sectionId}`,
|
||||
@@ -42,10 +42,7 @@ export const sectionRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const rawOptions = superjson.parse(section.options ?? emptySuperJSON);
|
||||
const parsedOptions =
|
||||
section.kind === "category"
|
||||
? categorySectionOptionsSchema.safeParse(rawOptions)
|
||||
: dynamicSectionOptionsSchema.safeParse(rawOptions);
|
||||
const parsedOptions = containerSectionOptionsSchema.safeParse(rawOptions);
|
||||
|
||||
if (!parsedOptions.success || !parsedOptions.data.collapsible) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -572,31 +572,34 @@ describe("getBoardByName should return board by name", () => {
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
|
||||
});
|
||||
|
||||
it("normalizes legacy category behavior options in the response", async () => {
|
||||
it("returns configured container behavior", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
await db
|
||||
.update(sections)
|
||||
.set({
|
||||
kind: "category",
|
||||
name: "Media",
|
||||
})
|
||||
.where(eq(sections.id, sectionId));
|
||||
|
||||
const result = await caller.getBoardByName({ name: "default" });
|
||||
const section = expectToBeDefined(result.sections[0]);
|
||||
|
||||
expect(result.id).toBe(boardId);
|
||||
expect(section.kind).toBe("category");
|
||||
if (section.kind === "category") {
|
||||
expect(section.collapsed).toBe(true);
|
||||
expect(section.options).toEqual({
|
||||
showLabel: true,
|
||||
const { boardId, sectionId: rootSectionId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const sectionId = await addContainerAsync(db, {
|
||||
boardId,
|
||||
layoutId,
|
||||
parentSectionId: rootSectionId,
|
||||
options: {
|
||||
title: "Media",
|
||||
showLabel: false,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.getBoardByName({ name: "default" });
|
||||
const section = expectToBeDefined(result.sections.find((candidate) => candidate.id === sectionId));
|
||||
|
||||
expect(result.id).toBe(boardId);
|
||||
expect(section.kind).toBe("container");
|
||||
if (section.kind === "container") {
|
||||
expect(section.collapsed).toBe(false);
|
||||
expect(section.options).toMatchObject({
|
||||
title: "Media",
|
||||
showLabel: false,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
railPlacement: "main",
|
||||
columnCount: 2,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -857,83 +860,92 @@ describe("saveBoard should save full board", () => {
|
||||
expect(integration).toBeUndefined();
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||
});
|
||||
it.each([[{ kind: "empty" as const }], [{ kind: "category" as const, collapsed: false, name: "My first category" }]])(
|
||||
"should add section when present in input",
|
||||
async (partialSection) => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
const newSectionId = createId();
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: newSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
...partialSection,
|
||||
},
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
});
|
||||
|
||||
const board = await db.query.boards.findFirst({
|
||||
where: eq(boards.id, boardId),
|
||||
with: {
|
||||
sections: true,
|
||||
},
|
||||
});
|
||||
|
||||
const section = await db.query.sections.findFirst({
|
||||
where: eq(sections.id, newSectionId),
|
||||
});
|
||||
|
||||
const definedBoard = expectToBeDefined(board);
|
||||
expect(definedBoard.sections.length).toBe(2);
|
||||
const addedSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
|
||||
expect(addedSection).toBeDefined();
|
||||
expect(addedSection.id).toBe(newSectionId);
|
||||
expect(addedSection.kind).toBe(partialSection.kind);
|
||||
expect(addedSection.yOffset).toBe(1);
|
||||
if ("name" in partialSection) {
|
||||
expect(addedSection.name).toBe(partialSection.name);
|
||||
}
|
||||
expect(section).toBeDefined();
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||
},
|
||||
);
|
||||
it("should add and update category behavior options", async () => {
|
||||
it("should add root section when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId } = await createFullBoardAsync(db, "default");
|
||||
const sectionId = createId();
|
||||
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
const newSectionId = createId();
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: newSectionId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
},
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
});
|
||||
|
||||
const board = await db.query.boards.findFirst({
|
||||
where: eq(boards.id, boardId),
|
||||
with: {
|
||||
sections: true,
|
||||
},
|
||||
});
|
||||
|
||||
const section = await db.query.sections.findFirst({
|
||||
where: eq(sections.id, newSectionId),
|
||||
});
|
||||
|
||||
const definedBoard = expectToBeDefined(board);
|
||||
expect(definedBoard.sections.length).toBe(2);
|
||||
const addedSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
|
||||
expect(addedSection).toBeDefined();
|
||||
expect(addedSection.id).toBe(newSectionId);
|
||||
expect(addedSection.kind).toBe("empty");
|
||||
expect(addedSection.yOffset).toBe(1);
|
||||
expect(addedSection.name).toBeNull();
|
||||
expect(section).toBeDefined();
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||
});
|
||||
it("should add and update container behavior options", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId, sectionId: rootSectionId } = await createFullBoardAsync(db, "default");
|
||||
const containerId = createId();
|
||||
|
||||
await caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "category",
|
||||
name: "Navigation",
|
||||
id: rootSectionId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
id: containerId,
|
||||
kind: "container",
|
||||
collapsed: false,
|
||||
options: {
|
||||
title: "Navigation",
|
||||
customCssClasses: ["pinned"],
|
||||
borderColor: "#123456",
|
||||
showLabel: true,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
railPlacement: "left",
|
||||
columnCount: 3,
|
||||
},
|
||||
layouts: [
|
||||
{
|
||||
layoutId,
|
||||
parentSectionId: rootSectionId,
|
||||
xOffset: 1,
|
||||
yOffset: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
@@ -943,34 +955,60 @@ describe("saveBoard should save full board", () => {
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "category",
|
||||
name: "Navigation",
|
||||
id: rootSectionId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
id: containerId,
|
||||
kind: "container",
|
||||
collapsed: false,
|
||||
options: {
|
||||
title: "Operations",
|
||||
customCssClasses: ["dense"],
|
||||
borderColor: "#654321",
|
||||
showLabel: false,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
railPlacement: "right",
|
||||
columnCount: 4,
|
||||
},
|
||||
layouts: [
|
||||
{
|
||||
layoutId,
|
||||
parentSectionId: rootSectionId,
|
||||
xOffset: 2,
|
||||
yOffset: 3,
|
||||
width: 4,
|
||||
height: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
});
|
||||
|
||||
const section = await db.query.sections.findFirst({
|
||||
where: eq(sections.id, sectionId),
|
||||
where: eq(sections.id, containerId),
|
||||
});
|
||||
|
||||
expect(SuperJSON.parse(section?.options ?? "")).toEqual({
|
||||
title: "Operations",
|
||||
customCssClasses: ["dense"],
|
||||
borderColor: "#654321",
|
||||
showLabel: false,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
railPlacement: "right",
|
||||
columnCount: 4,
|
||||
});
|
||||
expect(
|
||||
await db.query.sectionLayouts.findFirst({
|
||||
where: and(eq(sectionLayouts.sectionId, containerId), eq(sectionLayouts.layoutId, layoutId)),
|
||||
}),
|
||||
).toMatchObject({
|
||||
parentSectionId: rootSectionId,
|
||||
xOffset: 2,
|
||||
yOffset: 3,
|
||||
width: 4,
|
||||
height: 5,
|
||||
});
|
||||
});
|
||||
it("should add item when present in input", async () => {
|
||||
@@ -1118,15 +1156,22 @@ describe("saveBoard should save full board", () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
const newSectionId = createId();
|
||||
const { boardId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const containerId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: newSectionId,
|
||||
kind: "category",
|
||||
name: "Before",
|
||||
yOffset: 1,
|
||||
xOffset: 0,
|
||||
id: containerId,
|
||||
kind: "container",
|
||||
boardId,
|
||||
options: SuperJSON.stringify({ title: "Before" }),
|
||||
});
|
||||
await db.insert(sectionLayouts).values({
|
||||
sectionId: containerId,
|
||||
layoutId,
|
||||
parentSectionId: sectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 2,
|
||||
});
|
||||
|
||||
await caller.saveBoard({
|
||||
@@ -1134,19 +1179,32 @@ describe("saveBoard should save full board", () => {
|
||||
sections: [
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "category",
|
||||
kind: "empty",
|
||||
yOffset: 1,
|
||||
xOffset: 0,
|
||||
name: "Test",
|
||||
collapsed: true,
|
||||
},
|
||||
{
|
||||
id: newSectionId,
|
||||
kind: "category",
|
||||
name: "After",
|
||||
yOffset: 0,
|
||||
xOffset: 0,
|
||||
id: containerId,
|
||||
kind: "container",
|
||||
collapsed: false,
|
||||
options: {
|
||||
title: "After",
|
||||
customCssClasses: ["dense"],
|
||||
borderColor: "#123456",
|
||||
showLabel: false,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
},
|
||||
layouts: [
|
||||
{
|
||||
layoutId,
|
||||
parentSectionId: sectionId,
|
||||
xOffset: 1,
|
||||
yOffset: 2,
|
||||
width: 3,
|
||||
height: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
@@ -1166,11 +1224,17 @@ describe("saveBoard should save full board", () => {
|
||||
expect(firstSection.kind).toBe("empty");
|
||||
expect(firstSection.yOffset).toBe(1);
|
||||
expect(firstSection.name).toBe(null);
|
||||
const secondSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
|
||||
expect(secondSection.id).toBe(newSectionId);
|
||||
expect(secondSection.kind).toBe("category");
|
||||
expect(secondSection.yOffset).toBe(0);
|
||||
expect(secondSection.name).toBe("After");
|
||||
const secondSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === containerId));
|
||||
expect(secondSection.id).toBe(containerId);
|
||||
expect(secondSection.kind).toBe("container");
|
||||
expect(secondSection.yOffset).toBeNull();
|
||||
expect(secondSection.name).toBeNull();
|
||||
expect(SuperJSON.parse(secondSection.options ?? "")).toMatchObject({ title: "After", collapsible: true });
|
||||
expect(
|
||||
await db.query.sectionLayouts.findFirst({
|
||||
where: and(eq(sectionLayouts.sectionId, containerId), eq(sectionLayouts.layoutId, layoutId)),
|
||||
}),
|
||||
).toMatchObject({ xOffset: 1, yOffset: 2, width: 3, height: 4, parentSectionId: sectionId });
|
||||
});
|
||||
it("should update item when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
@@ -1456,7 +1520,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
expect(definedLayout.columnCount).toBe(newLayout.columnCount);
|
||||
expect(definedLayout.breakpoint).toBe(newLayout.breakpoint);
|
||||
});
|
||||
test("should add items and dynamic sections generated from grid-algorithm when new layout is added", async () => {
|
||||
test("should add items and containers generated from grid-algorithm when new layout is added", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
@@ -1485,7 +1549,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
a: itemId,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layout!.id, assignments.inDynamicSection);
|
||||
await expectLayoutForContainerAsync(db, assignments.inRoot.f, layout!.id, assignments.inContainer);
|
||||
});
|
||||
test("should update layout when present in input", async () => {
|
||||
// Arrange
|
||||
@@ -1538,7 +1602,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
...assignments.inRoot,
|
||||
a: itemId,
|
||||
});
|
||||
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
|
||||
await expectLayoutForContainerAsync(db, assignments.inRoot.f, layoutId, assignments.inContainer);
|
||||
});
|
||||
test("should reserve gutter columns and return gutter contents to the canvas when disabled", async () => {
|
||||
const db = createDb();
|
||||
@@ -1806,19 +1870,21 @@ const addItemAsync = async (
|
||||
return itemId;
|
||||
};
|
||||
|
||||
const addDynamicSectionAsync = async (
|
||||
const addContainerAsync = async (
|
||||
db: Database,
|
||||
section: Partial<Pick<InferInsertModel<typeof sectionLayouts>, "xOffset" | "yOffset" | "width" | "height">> & {
|
||||
parentSectionId: string;
|
||||
boardId: string;
|
||||
layoutId: string;
|
||||
options?: Record<string, unknown>;
|
||||
},
|
||||
) => {
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
kind: "dynamic",
|
||||
kind: "container",
|
||||
boardId: section.boardId,
|
||||
options: SuperJSON.stringify(section.options ?? {}),
|
||||
});
|
||||
await db.insert(sectionLayouts).values({
|
||||
parentSectionId: section.parentSectionId,
|
||||
@@ -1860,7 +1926,7 @@ const createItemsAndSectionsAsync = async (
|
||||
const itemC = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 6, width: 2, height: 2 });
|
||||
const itemD = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 8, width: 2, height: 3 });
|
||||
const itemE = await addItemAsync(db, { boardId, layoutId, sectionId, yOffset: 1, height: 3 });
|
||||
const sectionF = await addDynamicSectionAsync(db, {
|
||||
const sectionF = await addContainerAsync(db, {
|
||||
yOffset: 1,
|
||||
xOffset: 1,
|
||||
width: 5,
|
||||
@@ -1869,7 +1935,7 @@ const createItemsAndSectionsAsync = async (
|
||||
boardId,
|
||||
layoutId,
|
||||
});
|
||||
const sectionG = await addDynamicSectionAsync(db, {
|
||||
const sectionG = await addContainerAsync(db, {
|
||||
yOffset: 2,
|
||||
xOffset: 6,
|
||||
width: 2,
|
||||
@@ -1900,7 +1966,7 @@ const createItemsAndSectionsAsync = async (
|
||||
f: sectionF,
|
||||
g: sectionG,
|
||||
},
|
||||
inDynamicSection: {
|
||||
inContainer: {
|
||||
h: itemH,
|
||||
i: itemI,
|
||||
j: itemJ,
|
||||
@@ -1936,7 +2002,7 @@ gg`,
|
||||
);
|
||||
};
|
||||
|
||||
const expectLayoutForDynamicSectionAsync = async (
|
||||
const expectLayoutForContainerAsync = async (
|
||||
db: Database,
|
||||
sectionId: string,
|
||||
layoutId: string,
|
||||
|
||||
@@ -42,33 +42,27 @@ const createCallerAsync = async () => {
|
||||
};
|
||||
|
||||
describe("changeCollapsed", () => {
|
||||
it("preserves collapse support for legacy categories", async () => {
|
||||
it("rejects a root section", async () => {
|
||||
const { boardId, caller, db } = await createCallerAsync();
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "category",
|
||||
name: "Media",
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
|
||||
await caller.changeCollapsed({ sectionId, collapsed: true });
|
||||
|
||||
const state = await db.query.sectionCollapseStates.findFirst({
|
||||
where: eq(sectionCollapseStates.sectionId, sectionId),
|
||||
});
|
||||
expect(state?.collapsed).toBe(true);
|
||||
await expect(caller.changeCollapsed({ sectionId, collapsed: true })).rejects.toThrow("Section cannot be collapsed");
|
||||
});
|
||||
|
||||
it("creates and updates collapse state for an enabled dynamic section", async () => {
|
||||
it("creates and updates collapse state for an enabled container", async () => {
|
||||
const { boardId, caller, db } = await createCallerAsync();
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "dynamic",
|
||||
kind: "container",
|
||||
options: stringify({ collapsible: true }),
|
||||
});
|
||||
|
||||
@@ -82,13 +76,13 @@ describe("changeCollapsed", () => {
|
||||
expect(states[0]?.collapsed).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a dynamic section when collapsing is disabled", async () => {
|
||||
it("rejects a container when collapsing is disabled", async () => {
|
||||
const { boardId, caller, db } = await createCallerAsync();
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "dynamic",
|
||||
kind: "container",
|
||||
});
|
||||
|
||||
await expect(caller.changeCollapsed({ sectionId, collapsed: true })).rejects.toThrow("Section cannot be collapsed");
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { categorySectionOptionsSchema, dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import {
|
||||
boards,
|
||||
@@ -19,10 +19,10 @@ import {
|
||||
sections,
|
||||
users,
|
||||
} from "../../schema";
|
||||
import { migrateCategoriesToUnifiedSectionsAsync } from "./0004_unify_sections_and_gutters";
|
||||
import { migrateLegacySectionsToContainersAsync } from "./0004_unify_sections_and_gutters";
|
||||
|
||||
describe("unified section migration", () => {
|
||||
test("converts a rail category into a resizable section inside a board gutter", async () => {
|
||||
describe("legacy section migration", () => {
|
||||
test("converts a rail category into a resizable container inside a board gutter", async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const boardId = createId();
|
||||
@@ -39,16 +39,17 @@ describe("unified section migration", () => {
|
||||
{
|
||||
id: categoryId,
|
||||
boardId,
|
||||
kind: "category",
|
||||
kind: "category" as never,
|
||||
name: "Pinned apps",
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
options: stringify(
|
||||
categorySectionOptionsSchema.parse({
|
||||
railPlacement: "left",
|
||||
columnCount: 2,
|
||||
}),
|
||||
),
|
||||
options: stringify({
|
||||
showLabel: false,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
railPlacement: "left",
|
||||
columnCount: 2,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
await db.insert(items).values({ id: itemId, boardId, kind: "clock" });
|
||||
@@ -68,7 +69,7 @@ describe("unified section migration", () => {
|
||||
height: 1,
|
||||
});
|
||||
|
||||
await migrateCategoriesToUnifiedSectionsAsync(db as unknown as Database);
|
||||
await migrateLegacySectionsToContainersAsync(db as unknown as Database);
|
||||
|
||||
const migratedLayout = await db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) });
|
||||
expect(migratedLayout?.leftGutterColumnCount).toBe(2);
|
||||
@@ -79,8 +80,15 @@ describe("unified section migration", () => {
|
||||
expect(gutterRoot).toBeDefined();
|
||||
|
||||
const migratedCategory = await db.query.sections.findFirst({ where: eq(sections.id, categoryId) });
|
||||
expect(migratedCategory?.kind).toBe("dynamic");
|
||||
expect(dynamicSectionOptionsSchema.parse(parse(migratedCategory?.options ?? "{}")).title).toBe("Pinned apps");
|
||||
expect(migratedCategory?.kind).toBe("container");
|
||||
expect(containerSectionOptionsSchema.parse(parse(migratedCategory?.options ?? "{}"))).toEqual({
|
||||
title: "Pinned apps",
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
showLabel: false,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
});
|
||||
const migratedCollapseState = await db.query.sectionCollapseStates.findFirst({
|
||||
where: and(eq(sectionCollapseStates.sectionId, categoryId), eq(sectionCollapseStates.userId, userId)),
|
||||
});
|
||||
@@ -100,4 +108,61 @@ describe("unified section migration", () => {
|
||||
});
|
||||
expect(nestedItem?.sectionId).toBe(categoryId);
|
||||
});
|
||||
|
||||
test("renames a dynamic section and preserves all container options and geometry", async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const boardId = createId();
|
||||
const layoutId = createId();
|
||||
const rootId = createId();
|
||||
const sectionId = createId();
|
||||
const options = {
|
||||
title: "Operations",
|
||||
customCssClasses: ["dense", "accent"],
|
||||
borderColor: "#123456",
|
||||
showLabel: false,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
};
|
||||
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(boards).values({ id: boardId, name: "dynamic-migration", creatorId: userId });
|
||||
await db.insert(layouts).values({ id: layoutId, name: "Base", boardId, columnCount: 8, breakpoint: 0 });
|
||||
await db.insert(sections).values([
|
||||
{ id: rootId, boardId, kind: "empty", xOffset: 0, yOffset: 0 },
|
||||
{
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "dynamic" as never,
|
||||
options: stringify(options),
|
||||
},
|
||||
]);
|
||||
await db.insert(sectionLayouts).values({
|
||||
sectionId,
|
||||
layoutId,
|
||||
parentSectionId: rootId,
|
||||
xOffset: 2,
|
||||
yOffset: 3,
|
||||
width: 4,
|
||||
height: 5,
|
||||
});
|
||||
|
||||
await migrateLegacySectionsToContainersAsync(db as unknown as Database);
|
||||
await migrateLegacySectionsToContainersAsync(db as unknown as Database);
|
||||
|
||||
const migrated = await db.query.sections.findFirst({ where: eq(sections.id, sectionId) });
|
||||
expect(migrated?.kind).toBe("container");
|
||||
expect(containerSectionOptionsSchema.parse(parse(migrated?.options ?? "{}"))).toEqual(options);
|
||||
expect(
|
||||
await db.query.sectionLayouts.findFirst({
|
||||
where: and(eq(sectionLayouts.sectionId, sectionId), eq(sectionLayouts.layoutId, layoutId)),
|
||||
}),
|
||||
).toMatchObject({
|
||||
parentSectionId: rootId,
|
||||
xOffset: 2,
|
||||
yOffset: 3,
|
||||
width: 4,
|
||||
height: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { parse, stringify } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import type { BoardLane } from "@homarr/definitions";
|
||||
import { emptySuperJSON, getRootSectionLane, rootSectionOffsets } from "@homarr/definitions";
|
||||
import { categorySectionOptionsSchema, dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { and, eq, handleTransactionsAsync, inArray } from "../..";
|
||||
import type { Database } from "../..";
|
||||
@@ -50,13 +51,30 @@ interface MigrationOperation {
|
||||
applySync(db: HomarrDatabase): void;
|
||||
}
|
||||
|
||||
const legacyCategoryOptionsDefaults = {
|
||||
showLabel: true,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
railPlacement: "main",
|
||||
columnCount: 2,
|
||||
} as const;
|
||||
|
||||
const legacyCategoryOptionsSchema = z
|
||||
.object({
|
||||
showLabel: z.boolean().default(legacyCategoryOptionsDefaults.showLabel),
|
||||
collapsible: z.boolean().default(legacyCategoryOptionsDefaults.collapsible),
|
||||
showOpenAll: z.boolean().default(legacyCategoryOptionsDefaults.showOpenAll),
|
||||
railPlacement: z.enum(["main", "left", "right"]).default(legacyCategoryOptionsDefaults.railPlacement),
|
||||
columnCount: z.number().int().min(1).max(24).default(legacyCategoryOptionsDefaults.columnCount),
|
||||
})
|
||||
.default(legacyCategoryOptionsDefaults);
|
||||
|
||||
/**
|
||||
* Categories and dynamic sections used to be separate concepts. Categories
|
||||
* were full-width roots and also doubled as side rails. Convert every category
|
||||
* into the same resizable section used by the canvas, and create internal
|
||||
* gutter roots for the board-level left/right sidebar settings.
|
||||
* Convert legacy categories and dynamic sections into durable containers.
|
||||
* Categories were full-width roots and also doubled as side rails, so they
|
||||
* additionally need placement and gutter migration.
|
||||
*/
|
||||
export async function migrateCategoriesToUnifiedSectionsAsync(db: Database) {
|
||||
export async function migrateLegacySectionsToContainersAsync(db: Database) {
|
||||
const existingBoards = await db.query.boards.findMany({
|
||||
with: {
|
||||
layouts: true,
|
||||
@@ -74,15 +92,37 @@ export async function migrateCategoriesToUnifiedSectionsAsync(db: Database) {
|
||||
},
|
||||
});
|
||||
|
||||
let migratedCategoryCount = 0;
|
||||
let migratedSectionCount = 0;
|
||||
|
||||
for (const board of existingBoards) {
|
||||
const categories = board.sections.filter((section) => section.kind === "category");
|
||||
if (categories.length === 0) continue;
|
||||
const categories = board.sections.filter((section) => (section.kind as string) === "category");
|
||||
const legacyContainers = board.sections.filter((section) => (section.kind as string) === "dynamic");
|
||||
if (categories.length === 0 && legacyContainers.length === 0) continue;
|
||||
const operations: MigrationOperation[] = [];
|
||||
|
||||
const appendLegacyContainerOperations = () => {
|
||||
for (const section of legacyContainers) {
|
||||
operations.push(
|
||||
updateSectionOperation(section.id, {
|
||||
kind: "container",
|
||||
xOffset: null,
|
||||
yOffset: null,
|
||||
name: null,
|
||||
options: stringify(containerSectionOptionsSchema.parse(parse(section.options ?? emptySuperJSON))),
|
||||
}),
|
||||
);
|
||||
migratedSectionCount += 1;
|
||||
}
|
||||
};
|
||||
|
||||
if (categories.length === 0) {
|
||||
appendLegacyContainerOperations();
|
||||
await executeBoardMigrationOperationsAsync(db, operations);
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryDetails = categories.map((category) => {
|
||||
const options = categorySectionOptionsSchema.parse(parse(category.options ?? emptySuperJSON));
|
||||
const options = legacyCategoryOptionsSchema.parse(parse(category.options ?? emptySuperJSON));
|
||||
return {
|
||||
category,
|
||||
lane: options.railPlacement as BoardLane,
|
||||
@@ -247,12 +287,12 @@ export async function migrateCategoriesToUnifiedSectionsAsync(db: Database) {
|
||||
|
||||
operations.push(
|
||||
updateSectionOperation(category.id, {
|
||||
kind: "dynamic",
|
||||
kind: "container",
|
||||
xOffset: null,
|
||||
yOffset: null,
|
||||
name: null,
|
||||
options: stringify(
|
||||
dynamicSectionOptionsSchema.parse({
|
||||
containerSectionOptionsSchema.parse({
|
||||
title: (category.name ?? "").slice(0, 64),
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
@@ -263,9 +303,11 @@ export async function migrateCategoriesToUnifiedSectionsAsync(db: Database) {
|
||||
),
|
||||
}),
|
||||
);
|
||||
migratedCategoryCount += 1;
|
||||
migratedSectionCount += 1;
|
||||
}
|
||||
|
||||
appendLegacyContainerOperations();
|
||||
|
||||
const retainedRootIds = new Set(Array.from(rootByLane.values(), (root) => root.id));
|
||||
const obsoleteRootIds = emptyRoots.filter((root) => !retainedRootIds.has(root.id)).map((root) => root.id);
|
||||
if (obsoleteRootIds.length > 0) {
|
||||
@@ -275,8 +317,8 @@ export async function migrateCategoriesToUnifiedSectionsAsync(db: Database) {
|
||||
await executeBoardMigrationOperationsAsync(db, operations);
|
||||
}
|
||||
|
||||
if (migratedCategoryCount > 0) {
|
||||
console.log(`Migrated categories to unified sections count="${migratedCategoryCount}"`);
|
||||
if (migratedSectionCount > 0) {
|
||||
console.log(`Migrated legacy sections to containers count="${migratedSectionCount}"`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +341,7 @@ const getDirectPlacements = (board: MigrationBoard, layoutId: string, sectionId:
|
||||
: [];
|
||||
}),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "dynamic") return [];
|
||||
if (section.kind !== "dynamic" && section.kind !== "container") return [];
|
||||
const layout = section.layouts.find(
|
||||
(candidate) => candidate.layoutId === layoutId && candidate.parentSectionId === sectionId,
|
||||
);
|
||||
|
||||
@@ -3,12 +3,12 @@ import { migrateReleaseWidgetProviderToOptionsAsync } from "./0000_release_widge
|
||||
import { migrateOpnsenseCredentialsAsync } from "./0001_opnsense_credentials";
|
||||
import { migrateAppWidgetShowDescriptionTooltipToDisplayModeAsync } from "./0002_app_widget_show_description_tooltip_to_display_mode";
|
||||
import { migrateWidgetOnlyIntegrationsToOptionsAsync } from "./0003_remove_widget_only_integrations";
|
||||
import { migrateCategoriesToUnifiedSectionsAsync } from "./0004_unify_sections_and_gutters";
|
||||
import { migrateLegacySectionsToContainersAsync } from "./0004_unify_sections_and_gutters";
|
||||
|
||||
export const applyCustomMigrationsAsync = async (db: Database) => {
|
||||
await migrateReleaseWidgetProviderToOptionsAsync(db);
|
||||
await migrateOpnsenseCredentialsAsync(db);
|
||||
await migrateAppWidgetShowDescriptionTooltipToDisplayModeAsync(db);
|
||||
await migrateWidgetOnlyIntegrationsToOptionsAsync(db);
|
||||
await migrateCategoriesToUnifiedSectionsAsync(db);
|
||||
await migrateLegacySectionsToContainersAsync(db);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
export const sectionKinds = ["category", "empty", "dynamic"] as const;
|
||||
export const sectionKinds = ["empty", "container"] as const;
|
||||
export type SectionKind = (typeof sectionKinds)[number];
|
||||
|
||||
export const boardLanes = ["left", "main", "right"] as const;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { sections } from "@homarr/db/schema";
|
||||
import type { OldmarrConfig } from "@homarr/old-schema";
|
||||
|
||||
const logger = createLogger({ module: "importSections" });
|
||||
|
||||
export const insertSectionsAsync = async (
|
||||
db: Database,
|
||||
categories: OldmarrConfig["categories"],
|
||||
wrappers: OldmarrConfig["wrappers"],
|
||||
boardId: string,
|
||||
) => {
|
||||
logger.info("Importing old homarr sections", { boardId, categories: categories.length, wrappers: wrappers.length });
|
||||
|
||||
const wrapperIds = wrappers.map((section) => section.id);
|
||||
const categoryIds = categories.map((section) => section.id);
|
||||
const idMaps = new Map<string, string>([...wrapperIds, ...categoryIds].map((id) => [id, createId()]));
|
||||
|
||||
const wrappersToInsert = wrappers.map((section) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
id: idMaps.get(section.id)!,
|
||||
boardId,
|
||||
xOffset: 0,
|
||||
yOffset: section.position,
|
||||
kind: "empty" as const,
|
||||
}));
|
||||
|
||||
const categoriesToInsert = categories.map((section) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
id: idMaps.get(section.id)!,
|
||||
boardId,
|
||||
xOffset: 0,
|
||||
yOffset: section.position,
|
||||
kind: "category" as const,
|
||||
name: section.name,
|
||||
}));
|
||||
|
||||
if (wrappersToInsert.length > 0) {
|
||||
await db.insert(sections).values(wrappersToInsert);
|
||||
}
|
||||
|
||||
if (categoriesToInsert.length > 0) {
|
||||
await db.insert(sections).values(categoriesToInsert);
|
||||
}
|
||||
|
||||
logger.info("Imported sections", { count: wrappersToInsert.length + categoriesToInsert.length });
|
||||
|
||||
return idMaps;
|
||||
};
|
||||
@@ -12,7 +12,7 @@ import { mapColumnCount } from "../../mappers/map-column-count";
|
||||
import { moveWidgetsAndAppsIfMerge } from "../../move-widgets-and-apps-merge";
|
||||
import { prepareItems } from "../../prepare/prepare-items";
|
||||
import type { prepareMultipleImports } from "../../prepare/prepare-multiple";
|
||||
import { prepareSections } from "../../prepare/prepare-sections";
|
||||
import { placePreparedSections, prepareSections } from "../../prepare/prepare-sections";
|
||||
import type { InitialOldmarrImportSettings } from "../../settings";
|
||||
|
||||
const logger = createLogger({ module: "boardCollection" });
|
||||
@@ -26,6 +26,7 @@ export const createBoardInsertCollection = (
|
||||
"boards",
|
||||
"layouts",
|
||||
"sections",
|
||||
"sectionLayouts",
|
||||
"items",
|
||||
"itemLayouts",
|
||||
]);
|
||||
@@ -87,21 +88,18 @@ export const createBoardInsertCollection = (
|
||||
{} as Record<BoardSize, string>,
|
||||
);
|
||||
|
||||
insertCollection.layouts.push(
|
||||
...boardSizes.map((size) => ({
|
||||
id: layoutMapping[size],
|
||||
boardId: mappedBoard.id,
|
||||
columnCount: mapColumnCount(board.config.settings.customization.gridstack, size),
|
||||
breakpoint: mapBreakpoint(size),
|
||||
name: getBoardSizeName(size),
|
||||
})),
|
||||
);
|
||||
const preparedLayouts = boardSizes.map((size) => ({
|
||||
id: layoutMapping[size],
|
||||
boardId: mappedBoard.id,
|
||||
columnCount: mapColumnCount(board.config.settings.customization.gridstack, size),
|
||||
breakpoint: mapBreakpoint(size),
|
||||
name: getBoardSizeName(size),
|
||||
}));
|
||||
insertCollection.layouts.push(...preparedLayouts);
|
||||
|
||||
const preparedSections = prepareSections(mappedBoard.id, { wrappers, categories });
|
||||
|
||||
for (const section of preparedSections.values()) {
|
||||
insertCollection.sections.push(section);
|
||||
}
|
||||
insertCollection.sections.push(...preparedSections.sections);
|
||||
logger.debug("Added sections to board insert collection", { count: insertCollection.sections.length });
|
||||
|
||||
const preparedItems = prepareItems(
|
||||
@@ -111,10 +109,11 @@ export const createBoardInsertCollection = (
|
||||
settings: board.config.settings,
|
||||
},
|
||||
appsMap,
|
||||
preparedSections,
|
||||
preparedSections.byLegacyId,
|
||||
layoutMapping,
|
||||
mappedBoard.id,
|
||||
);
|
||||
insertCollection.sectionLayouts.push(...placePreparedSections(preparedSections, preparedItems, preparedLayouts));
|
||||
preparedItems.forEach(({ layouts, ...item }) => {
|
||||
insertCollection.items.push(item);
|
||||
insertCollection.itemLayouts.push(...layouts);
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
import { stringify } from "superjson";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import type { InferInsertModel } from "@homarr/db";
|
||||
import type { sections } from "@homarr/db/schema";
|
||||
import type { OldmarrCategorySection, OldmarrEmptySection } from "@homarr/old-schema";
|
||||
import { emptySuperJSON, rootSectionOffsets } from "@homarr/definitions";
|
||||
import type { OldmarrCategorySection } from "@homarr/old-schema";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
export const mapCategorySection = (
|
||||
export const mapLegacyCategoryContainer = (
|
||||
boardId: string,
|
||||
category: OldmarrCategorySection,
|
||||
): InferInsertModel<typeof sections> => ({
|
||||
id: createId(),
|
||||
boardId,
|
||||
kind: "category",
|
||||
xOffset: 0,
|
||||
yOffset: category.position,
|
||||
name: category.name,
|
||||
kind: "container",
|
||||
xOffset: null,
|
||||
yOffset: null,
|
||||
name: null,
|
||||
options: stringify(
|
||||
containerSectionOptionsSchema.parse({
|
||||
title: category.name.slice(0, 64),
|
||||
showLabel: true,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const mapEmptySection = (boardId: string, wrapper: OldmarrEmptySection): InferInsertModel<typeof sections> => ({
|
||||
export const mapMainRootSection = (boardId: string): InferInsertModel<typeof sections> => ({
|
||||
id: createId(),
|
||||
boardId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: wrapper.position,
|
||||
xOffset: rootSectionOffsets.main,
|
||||
yOffset: 0,
|
||||
name: null,
|
||||
options: emptySuperJSON,
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { parse } from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { placePreparedSections, prepareSections } from "./prepare-sections";
|
||||
|
||||
describe("prepareSections", () => {
|
||||
test("maps legacy blocks into one root and durable containers", () => {
|
||||
const prepared = prepareSections("board", {
|
||||
categories: [{ id: "category", name: "Media", position: 1 }],
|
||||
wrappers: [{ id: "wrapper", position: 0 }],
|
||||
});
|
||||
const root = prepared.sections.find((section) => section.kind === "empty");
|
||||
const container = prepared.sections.find((section) => section.kind === "container");
|
||||
if (!root || !container) throw new Error("Expected a root and a container");
|
||||
|
||||
expect(prepared.sections).toHaveLength(2);
|
||||
expect(root).toMatchObject({ boardId: "board", xOffset: 0, yOffset: 0 });
|
||||
expect(container).toMatchObject({ boardId: "board", xOffset: null, yOffset: null, name: null });
|
||||
expect(parse(container.options ?? "{}")).toMatchObject({
|
||||
title: "Media",
|
||||
showLabel: true,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
});
|
||||
expect(prepared.byLegacyId.get("category")?.id).toBe(container.id);
|
||||
expect(prepared.byLegacyId.get("wrapper")?.id).not.toBe(root.id);
|
||||
});
|
||||
|
||||
test("places root content and containers in legacy order for every layout", () => {
|
||||
const prepared = prepareSections("board", {
|
||||
categories: [{ id: "category", name: "Media", position: 1 }],
|
||||
wrappers: [{ id: "wrapper", position: 0 }],
|
||||
});
|
||||
const wrapperPlacementId = prepared.byLegacyId.get("wrapper")?.id;
|
||||
const containerId = prepared.byLegacyId.get("category")?.id;
|
||||
if (!wrapperPlacementId || !containerId) throw new Error("Expected mapped section ids");
|
||||
|
||||
const preparedItems = [
|
||||
{
|
||||
layouts: [
|
||||
{
|
||||
itemId: "root-item",
|
||||
layoutId: "layout",
|
||||
sectionId: wrapperPlacementId,
|
||||
xOffset: 1,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
layouts: [
|
||||
{
|
||||
itemId: "container-item",
|
||||
layoutId: "layout",
|
||||
sectionId: containerId,
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
width: 3,
|
||||
height: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const layouts = placePreparedSections(prepared, preparedItems, [{ id: "layout", columnCount: 12 }]);
|
||||
|
||||
expect(preparedItems[0]?.layouts[0]).toMatchObject({
|
||||
sectionId: prepared.mainRootId,
|
||||
xOffset: 1,
|
||||
yOffset: 0,
|
||||
});
|
||||
expect(preparedItems[1]?.layouts[0]).toMatchObject({ sectionId: containerId, yOffset: 1 });
|
||||
expect(layouts).toEqual([
|
||||
{
|
||||
sectionId: containerId,
|
||||
layoutId: "layout",
|
||||
parentSectionId: prepared.mainRootId,
|
||||
xOffset: 0,
|
||||
yOffset: 2,
|
||||
width: 12,
|
||||
height: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,115 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import type { InferInsertModel } from "@homarr/db";
|
||||
import type { itemLayouts, sectionLayouts, sections } from "@homarr/db/schema";
|
||||
import type { OldmarrConfig } from "@homarr/old-schema";
|
||||
|
||||
import { mapCategorySection, mapEmptySection } from "../mappers/map-section";
|
||||
import { mapLegacyCategoryContainer, mapMainRootSection } from "../mappers/map-section";
|
||||
|
||||
interface PreparedSectionBlock {
|
||||
id: string;
|
||||
legacyId: string;
|
||||
position: number;
|
||||
type: "container" | "root-content";
|
||||
}
|
||||
|
||||
interface PreparedSections {
|
||||
blocks: PreparedSectionBlock[];
|
||||
byLegacyId: Map<string, { id: string }>;
|
||||
mainRootId: string;
|
||||
sections: InferInsertModel<typeof sections>[];
|
||||
}
|
||||
|
||||
interface PreparedItemWithLayouts {
|
||||
layouts: InferInsertModel<typeof itemLayouts>[];
|
||||
}
|
||||
|
||||
export const prepareSections = (
|
||||
boardId: string,
|
||||
{ categories, wrappers }: Pick<OldmarrConfig, "categories" | "wrappers">,
|
||||
) =>
|
||||
new Map(
|
||||
categories
|
||||
.map((category) => [category.id, mapCategorySection(boardId, category)] as const)
|
||||
.concat(wrappers.map((wrapper) => [wrapper.id, mapEmptySection(boardId, wrapper)] as const)),
|
||||
);
|
||||
): PreparedSections => {
|
||||
const mainRoot = mapMainRootSection(boardId);
|
||||
const containers = categories.map((category) => ({
|
||||
category,
|
||||
section: mapLegacyCategoryContainer(boardId, category),
|
||||
}));
|
||||
const rootContent = wrappers.map((wrapper) => ({ wrapper, id: createId() }));
|
||||
|
||||
return {
|
||||
mainRootId: mainRoot.id,
|
||||
sections: [mainRoot, ...containers.map(({ section }) => section)],
|
||||
byLegacyId: new Map([
|
||||
...containers.map(({ category, section }) => [category.id, { id: section.id }] as const),
|
||||
...rootContent.map(({ wrapper, id }) => [wrapper.id, { id }] as const),
|
||||
]),
|
||||
blocks: [
|
||||
...containers.map(({ category, section }) => ({
|
||||
id: section.id,
|
||||
legacyId: category.id,
|
||||
position: category.position,
|
||||
type: "container" as const,
|
||||
})),
|
||||
...rootContent.map(({ wrapper, id }) => ({
|
||||
id,
|
||||
legacyId: wrapper.id,
|
||||
position: wrapper.position,
|
||||
type: "root-content" as const,
|
||||
})),
|
||||
].toSorted(
|
||||
(first, second) =>
|
||||
first.position - second.position ||
|
||||
(first.type === second.type
|
||||
? first.legacyId.localeCompare(second.legacyId)
|
||||
: first.type === "container"
|
||||
? -1
|
||||
: 1),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Old Homarr stored each top-level block in its own coordinate space. The
|
||||
* current model has one main root and places labelled containers inside it.
|
||||
* This compacts those blocks in their legacy order without changing the
|
||||
* coordinates of content inside a container.
|
||||
*/
|
||||
export const placePreparedSections = (
|
||||
preparedSections: PreparedSections,
|
||||
preparedItems: PreparedItemWithLayouts[],
|
||||
preparedLayouts: { id: string; columnCount: number }[],
|
||||
): InferInsertModel<typeof sectionLayouts>[] => {
|
||||
const result: InferInsertModel<typeof sectionLayouts>[] = [];
|
||||
|
||||
for (const layout of preparedLayouts) {
|
||||
let nextRow = 0;
|
||||
|
||||
for (const block of preparedSections.blocks) {
|
||||
const content = preparedItems.flatMap((item) =>
|
||||
item.layouts.filter((itemLayout) => itemLayout.layoutId === layout.id && itemLayout.sectionId === block.id),
|
||||
);
|
||||
const contentHeight = Math.max(0, ...content.map((itemLayout) => itemLayout.yOffset + itemLayout.height));
|
||||
|
||||
if (block.type === "container") {
|
||||
const height = Math.max(1, contentHeight);
|
||||
result.push({
|
||||
sectionId: block.id,
|
||||
layoutId: layout.id,
|
||||
parentSectionId: preparedSections.mainRootId,
|
||||
xOffset: 0,
|
||||
yOffset: nextRow,
|
||||
width: layout.columnCount,
|
||||
height,
|
||||
});
|
||||
nextRow += height;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const itemLayout of content) {
|
||||
itemLayout.sectionId = preparedSections.mainRootId;
|
||||
itemLayout.yOffset += nextRow;
|
||||
}
|
||||
nextRow += contentHeight;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -1549,7 +1549,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nova secció dinàmica",
|
||||
"remove": "Elimina la secció dinàmica"
|
||||
@@ -1569,46 +1569,6 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Nom"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Nova categoria",
|
||||
"edit": "Canvia el nom de la categoria",
|
||||
"remove": "Elimina la categoria",
|
||||
"moveUp": "Mou cap amunt",
|
||||
"moveDown": "Mou cap avall",
|
||||
"createAbove": "Nova categoria a sobre",
|
||||
"createBelow": "Nova categoria a sota",
|
||||
"openAllInNewTabs": "Obrir-ho tot en pestanyes"
|
||||
},
|
||||
"create": {
|
||||
"title": "Nova categoria",
|
||||
"submit": "Afegeix categoria"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Elimina la categoria",
|
||||
"message": "Esteu segur que voleu eliminar la categoria {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Canvia el nom de la categoria",
|
||||
"submit": "Canvia el nom de la categoria"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Nova categoria",
|
||||
"changePosition": "Canvia la posició",
|
||||
"settings": ""
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Obrir-ho tot en pestanyes",
|
||||
"text": "Alguns navegadors poden bloquejar l'obertura massiva de pestanyes per motius de seguretat. Homarr no ha pogut obrir totes les finestres perquè el teu navegador ha bloquejat aquesta acció. Si us plau, permet \"Obrir finestres emergents\" i torna-ho a intentar."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1549,7 +1549,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "新建动态部分",
|
||||
"remove": "删除动态部分"
|
||||
@@ -1569,46 +1569,6 @@
|
||||
"title": "删除动态部分",
|
||||
"message": "你确定你要删除这个动态部分?项目将被移动到父节的相同位置。"
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "名称"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "新建分类",
|
||||
"edit": "重命名分类",
|
||||
"remove": "删除分类",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移",
|
||||
"createAbove": "上方新建分类",
|
||||
"createBelow": "下方新建分类",
|
||||
"openAllInNewTabs": "在标签中打开全部"
|
||||
},
|
||||
"create": {
|
||||
"title": "新建分类",
|
||||
"submit": "添加分类"
|
||||
},
|
||||
"remove": {
|
||||
"title": "删除分类",
|
||||
"message": "您确定要删除分类 {name} 吗?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "重命名分类",
|
||||
"submit": "重命名分类"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "新建分类",
|
||||
"changePosition": "换位",
|
||||
"settings": "设置"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "在标签中打开全部",
|
||||
"text": "某些浏览器可能出于安全原因阻止大批打开标签页。 Homarr 无法打开所有窗口,因为您的浏览器屏蔽了此操作。请允许“打开弹出窗口”并重试。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1398,7 +1398,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "crwdns650:0crwdne650:0",
|
||||
"remove": "crwdns652:0crwdne652:0"
|
||||
@@ -1418,46 +1418,6 @@
|
||||
"title": "crwdns654:0crwdne654:0",
|
||||
"message": "crwdns656:0crwdne656:0"
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "crwdns658:0crwdne658:0"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "crwdns660:0crwdne660:0",
|
||||
"edit": "crwdns662:0crwdne662:0",
|
||||
"remove": "crwdns664:0crwdne664:0",
|
||||
"moveUp": "crwdns666:0crwdne666:0",
|
||||
"moveDown": "crwdns668:0crwdne668:0",
|
||||
"createAbove": "crwdns670:0crwdne670:0",
|
||||
"createBelow": "crwdns672:0crwdne672:0",
|
||||
"openAllInNewTabs": "crwdns2730:0crwdne2730:0"
|
||||
},
|
||||
"create": {
|
||||
"title": "crwdns674:0crwdne674:0",
|
||||
"submit": "crwdns676:0crwdne676:0"
|
||||
},
|
||||
"remove": {
|
||||
"title": "crwdns678:0crwdne678:0",
|
||||
"message": "crwdns680:0{name}crwdne680:0"
|
||||
},
|
||||
"edit": {
|
||||
"title": "crwdns682:0crwdne682:0",
|
||||
"submit": "crwdns684:0crwdne684:0"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "crwdns686:0crwdne686:0",
|
||||
"changePosition": "crwdns688:0crwdne688:0",
|
||||
"settings": "crwdns4259:0crwdne4259:0"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "crwdns2732:0crwdne2732:0",
|
||||
"text": "crwdns2734:0crwdne2734:0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1549,7 +1549,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nová dynamická sekce",
|
||||
"remove": "Odstranit dynamickou sekci"
|
||||
@@ -1569,46 +1569,6 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Název"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Nová kategorie",
|
||||
"edit": "Přejmenovat kategorii",
|
||||
"remove": "Odstranit kategorii",
|
||||
"moveUp": "Posunout nahoru",
|
||||
"moveDown": "Posunout dolů",
|
||||
"createAbove": "Nová kategorie nad",
|
||||
"createBelow": "Nová kategorie pod",
|
||||
"openAllInNewTabs": ""
|
||||
},
|
||||
"create": {
|
||||
"title": "Nová kategorie",
|
||||
"submit": "Přidat kategorii"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Odstranit kategorii",
|
||||
"message": "Jste si jisti, že chcete odstranit kategorii {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Přejmenovat kategorii",
|
||||
"submit": "Přejmenovat kategorii"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Nová kategorie",
|
||||
"changePosition": "Změnit pozici",
|
||||
"settings": "Nastavení"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1344,7 +1344,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Ny dynamisk sektion",
|
||||
"remove": "Fjern dynamisk sektion"
|
||||
@@ -1364,46 +1364,6 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Navn"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Ny kategori",
|
||||
"edit": "Omdøb kategori",
|
||||
"remove": "Fjern kategori",
|
||||
"moveUp": "Flyt op",
|
||||
"moveDown": "Flyt ned",
|
||||
"createAbove": "Ny kategori ovenover",
|
||||
"createBelow": "Ny kategori nedenunder",
|
||||
"openAllInNewTabs": "Åbn alt i faneblade"
|
||||
},
|
||||
"create": {
|
||||
"title": "Ny kategori",
|
||||
"submit": "Tilføj kategori"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Fjern kategori",
|
||||
"message": "Er du sikker på, at du vil fjerne kategorien {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Omdøb kategori",
|
||||
"submit": "Omdøb kategori"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Ny kategori",
|
||||
"changePosition": "Ændre placering",
|
||||
"settings": "Indstillinger"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Åbn alt i faneblade",
|
||||
"text": "Nogle browsere kan blokere bulk-åbning af faner af sikkerhedsmæssige årsager. Homarr kunne ikke åbne alle vinduer, fordi din browser blokerede denne handling. Tillad venligst \"Åbn pop op-vinduer\" og prøv igen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1344,7 +1344,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Neuer dynamischer Abschnitt",
|
||||
"remove": "Dynamischen Abschnitt entfernen"
|
||||
@@ -1364,46 +1364,6 @@
|
||||
"title": "Dynamischen Abschnitt entfernen",
|
||||
"message": "Möchten Sie diesen dynamischen Abschnitt wirklich entfernen? Die Elemente werden an die gleiche Position im übergeordneten Abschnitt verschoben."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Neue Kategorie",
|
||||
"edit": "Kategorie umbenennen",
|
||||
"remove": "Kategorie entfernen",
|
||||
"moveUp": "Nach oben bewegen",
|
||||
"moveDown": "Nach unten bewegen",
|
||||
"createAbove": "Neue Kategorie oben",
|
||||
"createBelow": "Neue Kategorie unten",
|
||||
"openAllInNewTabs": "Alle in Tabs öffnen"
|
||||
},
|
||||
"create": {
|
||||
"title": "Neue Kategorie",
|
||||
"submit": "Kategorie hinzufügen"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Kategorie entfernen",
|
||||
"message": "Möchten Sie die Kategorie {name} wirklich entfernen?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Kategorie umbenennen",
|
||||
"submit": "Kategorie umbenennen"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Neue Kategorie",
|
||||
"changePosition": "Position wechseln",
|
||||
"settings": ""
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Alle in tabs öffnen",
|
||||
"text": "Einige Browser können aus Sicherheitsgründen das Öffnen von mehreren Tabs aufeinmal blockieren. Homarr konnte nicht alle Fenster öffnen, weil Ihr Browser diese Aktion blockiert hat. Bitte erlauben Sie \"Pop-up Fenster öffnen\" und versuchen Sie es erneut."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1398,7 +1398,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Neuer dynamischer Abschnitt",
|
||||
"remove": "Dynamischen Abschnitt entfernen"
|
||||
@@ -1418,46 +1418,6 @@
|
||||
"title": "Dynamischen Abschnitt entfernen",
|
||||
"message": "Möchten Sie diesen dynamischen Abschnitt wirklich entfernen? Die Elemente werden an die gleiche Position im übergeordneten Abschnitt verschoben."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Name"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Neue Kategorie",
|
||||
"edit": "Kategorie umbenennen",
|
||||
"remove": "Kategorie entfernen",
|
||||
"moveUp": "Nach oben bewegen",
|
||||
"moveDown": "Nach unten bewegen",
|
||||
"createAbove": "Neue Kategorie oben",
|
||||
"createBelow": "Neue Kategorie unten",
|
||||
"openAllInNewTabs": "Alle in Tabs öffnen"
|
||||
},
|
||||
"create": {
|
||||
"title": "Neue Kategorie",
|
||||
"submit": "Kategorie hinzufügen"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Kategorie entfernen",
|
||||
"message": "Möchten Sie die Kategorie {name} wirklich entfernen?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Kategorie umbenennen",
|
||||
"submit": "Kategorie umbenennen"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Neue Kategorie",
|
||||
"changePosition": "Position wechseln",
|
||||
"settings": "Einstellungen"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Alle in tabs öffnen",
|
||||
"text": "Einige Browser können aus Sicherheitsgründen das gleichzeitige Öffnen von mehreren Tabs blockieren. Homarr konnte nicht alle Fenster öffnen, weil Ihr Browser diese Aktion blockiert hat. Bitte erlauben Sie \"Pop-up Fenster öffnen\" und versuchen Sie es erneut."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1549,7 +1549,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "",
|
||||
"remove": ""
|
||||
@@ -1569,46 +1569,6 @@
|
||||
"title": "",
|
||||
"message": ""
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Όνομα"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Νέα κατηγορία",
|
||||
"edit": "Μετονομασία κατηγορίας",
|
||||
"remove": "Αφαίρεση κατηγορίας",
|
||||
"moveUp": "Μετακίνηση επάνω",
|
||||
"moveDown": "Μετακίνηση κάτω",
|
||||
"createAbove": "Νέα κατηγορία επάνω",
|
||||
"createBelow": "Νέα κατηγορία κάτω",
|
||||
"openAllInNewTabs": ""
|
||||
},
|
||||
"create": {
|
||||
"title": "Νέα κατηγορία",
|
||||
"submit": "Προσθήκη κατηγορίας"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Αφαίρεση κατηγορίας",
|
||||
"message": ""
|
||||
},
|
||||
"edit": {
|
||||
"title": "Μετονομασία κατηγορίας",
|
||||
"submit": "Μετονομασία κατηγορίας"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Νέα κατηγορία",
|
||||
"changePosition": "Αλλαγή θέσης",
|
||||
"settings": "Ρυθμίσεις"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1549,10 +1549,10 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Create a new dynamic section",
|
||||
"remove": "Delete dynamic section"
|
||||
"create": "Create a new container",
|
||||
"remove": "Delete container"
|
||||
},
|
||||
"option": {
|
||||
"title": {
|
||||
@@ -1566,48 +1566,8 @@
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "Remove dynamic section",
|
||||
"message": "Are you certain that you want to remove this dynamic section? All items inside will be moved to the same location within the parent section."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Name"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "New category",
|
||||
"edit": "Rename category",
|
||||
"remove": "Remove category",
|
||||
"moveUp": "Move upwards",
|
||||
"moveDown": "Move downwards",
|
||||
"createAbove": "Create new category above",
|
||||
"createBelow": "Create new category below",
|
||||
"openAllInNewTabs": "Open all in new tabs"
|
||||
},
|
||||
"create": {
|
||||
"title": "New category",
|
||||
"submit": "Add category"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Remove category",
|
||||
"message": "Are you sure you want to remove the category {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Rename category",
|
||||
"submit": "Rename category"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "New category",
|
||||
"changePosition": "Change position",
|
||||
"settings": "Settings"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Open all in tabs",
|
||||
"text": "Some browsers may block the bulk-opening of tabs for security reasons. Homarr was unable to open all windows, because your browser blocked this action. Please allow \"Open pop-up windows\" and re-try."
|
||||
"title": "Remove container",
|
||||
"message": "Are you certain that you want to remove this container? All items inside will be moved to the same location within the parent section."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1344,7 +1344,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nueva sección dinámica",
|
||||
"remove": "Eliminar sección dinámica"
|
||||
@@ -1364,46 +1364,6 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Nombre"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Nueva Categoría",
|
||||
"edit": "Renombrar categoría",
|
||||
"remove": "Eliminar categoría",
|
||||
"moveUp": "Mover hacia arriba",
|
||||
"moveDown": "Mover hacia abajo",
|
||||
"createAbove": "Nueva categoría arriba",
|
||||
"createBelow": "Nueva categoría abajo",
|
||||
"openAllInNewTabs": "Abrir todo en nuevas pestañas"
|
||||
},
|
||||
"create": {
|
||||
"title": "Nueva Categoría",
|
||||
"submit": "Añadir categoría"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Eliminar categoría",
|
||||
"message": "¿Estás seguro de que deseas eliminar la categoría {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Renombrar categoría",
|
||||
"submit": "Renombrar categoría"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Nueva Categoría",
|
||||
"changePosition": "Cambiar posición",
|
||||
"settings": ""
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Abrir todo en nuevas pestañas",
|
||||
"text": "Algunos navegadores pueden bloquear la apertura masiva de nuevas pestañas por razones de seguridad. Homarr no pudo abrir todas las ventanas, porque tu navegador bloqueó esta acción. Por favor, permite \"Abrir ventanas emergentes\" y vuelve a intentarlo."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1549,7 +1549,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Uus dünaamiline sektsioon",
|
||||
"remove": "Eemalda dünaamiline sektsioon"
|
||||
@@ -1569,46 +1569,6 @@
|
||||
"title": "Eemalda dünaamiline sektsioon",
|
||||
"message": "Kas oled kindel, et soovid selle dünaamilise sektsiooni eemaldada? Üksused teisaldatakse ülemsektsiooni samasse asukohta."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Nimi"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Uus kategooria",
|
||||
"edit": "Nimeta kategooria ümber",
|
||||
"remove": "Eemalda kategooria",
|
||||
"moveUp": "Liiguta üles",
|
||||
"moveDown": "Liiguta alla",
|
||||
"createAbove": "Uus kategooria ülal",
|
||||
"createBelow": "Uus kategooria all",
|
||||
"openAllInNewTabs": ""
|
||||
},
|
||||
"create": {
|
||||
"title": "Uus kategooria",
|
||||
"submit": "Lisa kategooria"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Eemalda kategooria",
|
||||
"message": "Kas oled kindel, et soovid eemaldada kategooria {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Nimeta kategooria ümber",
|
||||
"submit": "Nimeta kategooria ümber"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Uus kategooria",
|
||||
"changePosition": "Muuda asukohta",
|
||||
"settings": ""
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1549,7 +1549,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Uusi dynaaminen osio",
|
||||
"remove": ""
|
||||
@@ -1569,46 +1569,6 @@
|
||||
"title": "",
|
||||
"message": "Haluatko varmasti poistaa tämän dynaamisen osion? Kohteet siirretään samaan paikkaan yläosiossa."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Uusi kategoria",
|
||||
"edit": "",
|
||||
"remove": "",
|
||||
"moveUp": "",
|
||||
"moveDown": "",
|
||||
"createAbove": "",
|
||||
"createBelow": "",
|
||||
"openAllInNewTabs": ""
|
||||
},
|
||||
"create": {
|
||||
"title": "",
|
||||
"submit": ""
|
||||
},
|
||||
"remove": {
|
||||
"title": "",
|
||||
"message": "Haluatko varmasti poistaa kategorian {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "",
|
||||
"submit": ""
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "",
|
||||
"changePosition": "",
|
||||
"settings": "Asetukset"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "",
|
||||
"text": "Jotkin selaimet voivat turvallisuussyistä estää välilehtien massa-avaamisen. Homarr ei voinut avata kaikkia ikkunoita, koska selaimesi esti toimenpiteen. Salli ponnahdusikkunoiden avaaminen ja yritä uudelleen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1549,7 +1549,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nouvelle section dynamique",
|
||||
"remove": "Supprimer la section dynamique"
|
||||
@@ -1569,46 +1569,6 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Nom"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Nouvelle catégorie",
|
||||
"edit": "Renommer la catégorie",
|
||||
"remove": "Supprimer la catégorie",
|
||||
"moveUp": "Monter",
|
||||
"moveDown": "Descendre",
|
||||
"createAbove": "Nouvelle catégorie au-dessus",
|
||||
"createBelow": "Nouvelle catégorie en dessous",
|
||||
"openAllInNewTabs": "Tout ouvrir dans des onglets"
|
||||
},
|
||||
"create": {
|
||||
"title": "Nouvelle catégorie",
|
||||
"submit": "Ajouter une catégorie"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Supprimer la catégorie",
|
||||
"message": "Êtes-vous sûr de vouloir supprimer la catégorie {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Renommer la catégorie",
|
||||
"submit": "Renommer la catégorie"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Nouvelle catégorie",
|
||||
"changePosition": "Modifier la position",
|
||||
"settings": "Réglages"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Tout ouvrir dans des onglets",
|
||||
"text": "Certains navigateurs peuvent bloquer l'ouverture en masse d'onglets pour des raisons de sécurité. Homarr n'a pas pu ouvrir toutes les fenêtres, car votre navigateur a bloqué cette action. Veuillez autoriser \"Ouvrir les fenêtres pop-ups\" et réessayer."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1344,7 +1344,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "מדור דינמי חדש",
|
||||
"remove": "הסר קטע דינמי"
|
||||
@@ -1364,46 +1364,6 @@
|
||||
"title": "הסר קטע דינמי",
|
||||
"message": "האם אתה בטוח שברצונך להסיר את הקטע הדינמי הזה? פריטים יועברו לאותו מיקום במדור האב."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "שם"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "קטגוריה חדשה",
|
||||
"edit": "שנה את שם הקטגוריה",
|
||||
"remove": "הסר קטגוריה",
|
||||
"moveUp": "הזזה למעלה",
|
||||
"moveDown": "הזזה למטה",
|
||||
"createAbove": "קטגוריה חדשה למעלה",
|
||||
"createBelow": "קטגוריה חדשה למטה",
|
||||
"openAllInNewTabs": "פתח הכל בכרטיסיות"
|
||||
},
|
||||
"create": {
|
||||
"title": "קטגוריה חדשה",
|
||||
"submit": "הוסף קטגוריה"
|
||||
},
|
||||
"remove": {
|
||||
"title": "הסר קטגוריה",
|
||||
"message": "האם אתה בטוח שברצונך להסיר את הקטגוריה {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "שנה את שם הקטגוריה",
|
||||
"submit": "שנה את שם הקטגוריה"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "קטגוריה חדשה",
|
||||
"changePosition": "שנה מיקום",
|
||||
"settings": ""
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "פתח הכל בכרטיסיות",
|
||||
"text": "דפדפנים מסוימים עשויים לחסום פתיחה בכמות גדולה של כרטיסיות מסיבות אבטחה. Homarr לא הצליח לפתוח את כל החלונות, מכיוון שהדפדפן שלך חסם את הפעולה הזו. אפשר \"פתח חלונות קופצים\" ונסה שוב."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1549,7 +1549,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nova dinamička sekcija",
|
||||
"remove": "Ukloni dinamičku sekciju"
|
||||
@@ -1569,46 +1569,6 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Naziv"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Nova kategorija",
|
||||
"edit": "Preimenuj kategoriju",
|
||||
"remove": "Ukloni kategoriju",
|
||||
"moveUp": "Pomakni se gore",
|
||||
"moveDown": "Pomicati prema dolje",
|
||||
"createAbove": "Nova kategorija iznad",
|
||||
"createBelow": "Nova kategorija ispod",
|
||||
"openAllInNewTabs": ""
|
||||
},
|
||||
"create": {
|
||||
"title": "Nova kategorija",
|
||||
"submit": "Dodaj kategoriju"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Ukloni kategoriju",
|
||||
"message": "Jeste li sigurni da želite ukloniti kategoriju {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Preimenuj kategoriju",
|
||||
"submit": "Preimenuj kategoriju"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Nova kategorija",
|
||||
"changePosition": "Promijenjen položaj",
|
||||
"settings": "Postavke"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1549,7 +1549,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Új dinamikus szakasz",
|
||||
"remove": "A dinamikus szakasz eltávolítása"
|
||||
@@ -1569,46 +1569,6 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Név"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Új kategória",
|
||||
"edit": "Kategória átnevezése",
|
||||
"remove": "Kategória eltávolítása",
|
||||
"moveUp": "Felfelé mozgatás",
|
||||
"moveDown": "Mozgatás le",
|
||||
"createAbove": "Új kategória fent",
|
||||
"createBelow": "Új kategória alább",
|
||||
"openAllInNewTabs": "Mindet lapokban megnyitni"
|
||||
},
|
||||
"create": {
|
||||
"title": "Új kategória",
|
||||
"submit": "Kategória hozzáadása"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Kategória eltávolítása",
|
||||
"message": "Biztosan el akarja távolítani a {name} kategóriát?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Kategória átnevezése",
|
||||
"submit": "Kategória átnevezése"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Új kategória",
|
||||
"changePosition": "Pozíció módosítása",
|
||||
"settings": "Beállítások"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Mindet lapokban megnyitni",
|
||||
"text": "Bizonyos böngészők biztonsági okokból megakadályozhatják a lapok tömeges megnyitását. A Homarr nem tudta megnyitni az összes ablakot, mert a böngészője letiltotta ezt a műveletet. Kérjük, engedélyezze a „Felugró ablakok megnyitása” opciót, majd próbálja meg újra."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1398,7 +1398,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nuova sezione dinamica",
|
||||
"remove": "Rimuovi sezione dinamica"
|
||||
@@ -1418,46 +1418,6 @@
|
||||
"title": "Rimuovi sezione dinamica",
|
||||
"message": "Vuoi davvero rimuovere questa sezione dinamica? Gli elementi verranno spostati nella stessa posizione nella sezione padre."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Nome"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Nuova categoria",
|
||||
"edit": "Rinomina categoria",
|
||||
"remove": "Rimuovi categoria",
|
||||
"moveUp": "Sposta in alto",
|
||||
"moveDown": "Sposta in basso",
|
||||
"createAbove": "Nuova categoria sopra",
|
||||
"createBelow": "Nuova categoria sotto",
|
||||
"openAllInNewTabs": "Apri tutto in schede"
|
||||
},
|
||||
"create": {
|
||||
"title": "Nuova categoria",
|
||||
"submit": "Aggiungi categoria"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Rimuovi categoria",
|
||||
"message": "Sei sicuro di voler rimuovere la categoria {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Rinomina categoria",
|
||||
"submit": "Rinomina categoria"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Nuova categoria",
|
||||
"changePosition": "Cambia posizione",
|
||||
"settings": "Impostazioni"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Apri tutto in schede",
|
||||
"text": "Alcuni browser potrebbero bloccare l'apertura multipla delle schede per motivi di sicurezza. Homarr non è riuscito ad aprire tutte le finestre perché il browser ha bloccato questa azione. Consenti l'apertura di finestre pop-up e riprova."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1344,7 +1344,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "新しいダイナミックセクション",
|
||||
"remove": "ダイナミックセクションを削除する"
|
||||
@@ -1364,46 +1364,6 @@
|
||||
"title": "ダイナミックセクションの削除",
|
||||
"message": "この動的セクションを削除してもよろしいですか?アイテムは親セクションの同じ場所に移動されます。"
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "名称"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "新しいカテゴリー",
|
||||
"edit": "カテゴリー名の変更",
|
||||
"remove": "カテゴリーを削除",
|
||||
"moveUp": "上に移動",
|
||||
"moveDown": "下へ移動",
|
||||
"createAbove": "上に新しいカテゴリー",
|
||||
"createBelow": "下に新しいカテゴリ",
|
||||
"openAllInNewTabs": "すべてのタブを開く"
|
||||
},
|
||||
"create": {
|
||||
"title": "新しいカテゴリー",
|
||||
"submit": "カテゴリーを追加"
|
||||
},
|
||||
"remove": {
|
||||
"title": "カテゴリーの削除",
|
||||
"message": "カテゴリー {name} を削除してもよろしいですか?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "カテゴリー名変更",
|
||||
"submit": "カテゴリー名を変更"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "新しいカテゴリー",
|
||||
"changePosition": "ポジションを変更する",
|
||||
"settings": ""
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "すべてのタブを開く",
|
||||
"text": "一部のブラウザーはセキュリティ上の理由からタブの一括開封をブロックすることがあります。 あなたのブラウザがこの操作をブロックしたため、Homarr はすべてのウィンドウを開くことができませんでした。「ポップアップウィンドウを開く」を許可して再試行してください。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1549,7 +1549,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "새 동적 섹션",
|
||||
"remove": "동적 섹션 제거"
|
||||
@@ -1569,46 +1569,6 @@
|
||||
"title": "동적 섹션 제거",
|
||||
"message": "이 동적 섹션을 제거하시겠습니까? 항목은 상위 섹션의 동일한 위치로 이동합니다."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "이름"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "새 카테고리",
|
||||
"edit": "카테고리 이름 변경",
|
||||
"remove": "카테고리 제거",
|
||||
"moveUp": "위로 이동",
|
||||
"moveDown": "아래로 이동",
|
||||
"createAbove": "위에 새 카테고리 생성",
|
||||
"createBelow": "아래 새 카테고리 생성",
|
||||
"openAllInNewTabs": "탭에서 모두 열기"
|
||||
},
|
||||
"create": {
|
||||
"title": "새 카테고리",
|
||||
"submit": "카테고리 추가"
|
||||
},
|
||||
"remove": {
|
||||
"title": "카테고리 제거",
|
||||
"message": "{name} 카테고리를 제거하시겠습니까?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "카테고리 이름 변경",
|
||||
"submit": "카테고리 이름 변경"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "새 카테고리",
|
||||
"changePosition": "위치 변경",
|
||||
"settings": "설정"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "탭에서 모두 열기",
|
||||
"text": "일부 브라우저는 보안상의 이유로 탭의 대량 열기를 차단할 수 있습니다. 브라우저가 이 작업을 차단했기 때문에 Homarr가 모든 창을 열지 못했습니다. \"팝업 창 열기\"를 허용한 후 다시 시도해 주십시오."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1549,7 +1549,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nauja dinaminė sekcija",
|
||||
"remove": "Pašalinti dinaminę sekciją"
|
||||
@@ -1569,46 +1569,6 @@
|
||||
"title": "Pašalinti dinaminę sekciją",
|
||||
"message": "Ar tikrai norite pašalinti šią dinaminę sekciją? Elementai bus perkelti į tą pačią vietą pirminėje sekcijoje."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Pavadinimas"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Nauja kategorija",
|
||||
"edit": "Pervardyti kategoriją",
|
||||
"remove": "Pašalinti kategoriją",
|
||||
"moveUp": "Pakelti aukštyn",
|
||||
"moveDown": "Perkelti žemyn",
|
||||
"createAbove": "Nauja kategorija aukščiau",
|
||||
"createBelow": "Nauja kategorija žemiau",
|
||||
"openAllInNewTabs": ""
|
||||
},
|
||||
"create": {
|
||||
"title": "Nauja kategorija",
|
||||
"submit": "Pridėti kategoriją"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Pašalinti kategoriją",
|
||||
"message": "Ar tikrai norite pašalinti kategoriją {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Pervardyti kategoriją",
|
||||
"submit": "Pervardyti kategoriją"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Nauja kategorija",
|
||||
"changePosition": "Keisti poziciją",
|
||||
"settings": "Nustatymai"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1549,7 +1549,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Jauna dinamiskā sadaļa",
|
||||
"remove": "Noņemt dinamisko sadaļu"
|
||||
@@ -1569,46 +1569,6 @@
|
||||
"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ļā."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Nosaukums"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Jauna kategorija",
|
||||
"edit": "Pārdēvēt kategoriju",
|
||||
"remove": "Noņemt kategoriju",
|
||||
"moveUp": "Virzīt augšup",
|
||||
"moveDown": "Virzīt lejup",
|
||||
"createAbove": "Jauna kategorija augšā",
|
||||
"createBelow": "Jauna kategorija apakšā",
|
||||
"openAllInNewTabs": ""
|
||||
},
|
||||
"create": {
|
||||
"title": "Jauna kategorija",
|
||||
"submit": "Pievienot kategoriju"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Noņemt kategoriju",
|
||||
"message": "Vai esat pārliecināts, ka vēlaties noņemt kategoriju {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Pārdēvēt kategoriju",
|
||||
"submit": "Pārdēvēt kategoriju"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Jauna kategorija",
|
||||
"changePosition": "Mainīt pozīciju",
|
||||
"settings": "Iestatījumi"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1344,7 +1344,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nieuwe dynamische sectie",
|
||||
"remove": "Dynamische sectie verwijderen"
|
||||
@@ -1364,46 +1364,6 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Naam"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Nieuwe categorie",
|
||||
"edit": "Categorie hernoemen",
|
||||
"remove": "Categorie verwijderen",
|
||||
"moveUp": "Omhoog",
|
||||
"moveDown": "Omlaag",
|
||||
"createAbove": "Nieuwe categorie hierboven",
|
||||
"createBelow": "Nieuwe categorie hieronder",
|
||||
"openAllInNewTabs": "Alles openen in tabbladen"
|
||||
},
|
||||
"create": {
|
||||
"title": "Nieuwe categorie",
|
||||
"submit": "Categorie toevoegen"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Categorie verwijderen",
|
||||
"message": "Weet je zeker dat je de categorie {name} wilt verwijderen?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Categorie hernoemen",
|
||||
"submit": "Categorie hernoemen"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Nieuwe categorie",
|
||||
"changePosition": "Positie wijzigen",
|
||||
"settings": "Instellingen"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Alles openen in tabbladen",
|
||||
"text": "Sommige browsers blokkeren om veiligheidsredenen het bulk openen van tabbladen. Homarr kon niet alle vensters openen, omdat je browser deze actie blokkeerde. Sta \"Pop-upvensters openen\" toe en probeer het opnieuw."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1344,7 +1344,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Ny dynamisk seksjon",
|
||||
"remove": "Fjern dynamisk seksjon"
|
||||
@@ -1364,46 +1364,6 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Navn"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Ny kategori",
|
||||
"edit": "Endre kategorinavn",
|
||||
"remove": "Fjern kategori",
|
||||
"moveUp": "Flytte opp",
|
||||
"moveDown": "Flytt ned",
|
||||
"createAbove": "Ny kategori over",
|
||||
"createBelow": "Ny kategori under",
|
||||
"openAllInNewTabs": "Åpne alle i faner"
|
||||
},
|
||||
"create": {
|
||||
"title": "Ny kategori",
|
||||
"submit": "Legg til kategori"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Fjern kategori",
|
||||
"message": "Er du sikker på at du vil slette kategorien {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Endre kategorinavn",
|
||||
"submit": "Endre kategorinavn"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Ny kategori",
|
||||
"changePosition": "Endre posisjon",
|
||||
"settings": ""
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Åpne alle i faner",
|
||||
"text": "Noen nettlesere kan blokkere åpning av faner av sikkerhetsgrunner. Homarr kunne ikke åpne alle vinduer, fordi nettleseren din blokkerte denne handlingen. Vennligst tillat \"Åpne pop-up vinduer\" og prøv på nytt."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1398,7 +1398,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nowa sekcja dynamiczna",
|
||||
"remove": "Usuń sekcję dynamiczną"
|
||||
@@ -1418,46 +1418,6 @@
|
||||
"title": "Usuń sekcję dynamiczną",
|
||||
"message": "Czy na pewno chcesz usunąć tę sekcję dynamiczną? Elementy zostaną przeniesione w tej samej lokalizacji w sekcji nadrzędnej."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Nazwa"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Nowa kategoria",
|
||||
"edit": "Zmień nazwę kategorii",
|
||||
"remove": "Usuń kategorię",
|
||||
"moveUp": "Przenieś w górę",
|
||||
"moveDown": "Przenieś w dół",
|
||||
"createAbove": "Nowa kategoria powyżej",
|
||||
"createBelow": "Nowa kategoria poniżej",
|
||||
"openAllInNewTabs": "Otwórz wszystkie w kartach"
|
||||
},
|
||||
"create": {
|
||||
"title": "Nowa kategoria",
|
||||
"submit": "Dodaj kategorię"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Usuń kategorię",
|
||||
"message": "Czy na pewno chcesz usunąć kategorię {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Zmień nazwę kategorii",
|
||||
"submit": "Zmień nazwę kategorii"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Nowa kategoria",
|
||||
"changePosition": "Zmiana pozycji",
|
||||
"settings": "Ustawienia"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Otwórz wszystkie w kartach",
|
||||
"text": "Niektóre przeglądarki mogą blokować masowe otwieranie kart ze względów bezpieczeństwa. Homarr nie był w stanie otworzyć wszystkich okien, ponieważ Twoja przeglądarka zablokowała tę akcję. Proszę zezwolić na \"Otwórz wyskakujące okna\" i spróbować ponownie."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1549,7 +1549,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nova seção dinâmica",
|
||||
"remove": "Remover seção dinâmica"
|
||||
@@ -1569,46 +1569,6 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Nome"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Nova categoria",
|
||||
"edit": "Renomear categoria",
|
||||
"remove": "Remover categoria",
|
||||
"moveUp": "Subir",
|
||||
"moveDown": "Mover para baixo",
|
||||
"createAbove": "Nova categoria acima",
|
||||
"createBelow": "Nova categoria abaixo",
|
||||
"openAllInNewTabs": "Abrir tudo em abas"
|
||||
},
|
||||
"create": {
|
||||
"title": "Nova categoria",
|
||||
"submit": "Adicionar categoria"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Remover categoria",
|
||||
"message": "Tem certeza que deseja deletar a categoria {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Renomear categoria",
|
||||
"submit": "Renomear categoria"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Nova categoria",
|
||||
"changePosition": "Mudar de posição",
|
||||
"settings": "Configurações"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Abrir tudo em abas",
|
||||
"text": "Alguns navegadores podem bloquear a abertura em massa de abas por motivos de segurança. O Homarr não conseguiu abrir todas as janelas porque seu navegador bloqueou esta ação. Por favor, permita \"Abrir janelas pop-up\" e tente novamente."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1549,7 +1549,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nova seção dinâmica",
|
||||
"remove": "Remover seção dinâmica"
|
||||
@@ -1569,46 +1569,6 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Nome"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Nova categoria",
|
||||
"edit": "Renomear categoria",
|
||||
"remove": "Remover categoria",
|
||||
"moveUp": "Subir",
|
||||
"moveDown": "Mover para baixo",
|
||||
"createAbove": "Nova categoria acima",
|
||||
"createBelow": "Nova categoria abaixo",
|
||||
"openAllInNewTabs": ""
|
||||
},
|
||||
"create": {
|
||||
"title": "Nova categoria",
|
||||
"submit": "Adicionar categoria"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Remover categoria",
|
||||
"message": "Tem certeza que deseja deletar a categoria {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Renomear categoria",
|
||||
"submit": "Renomear categoria"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Nova categoria",
|
||||
"changePosition": "Mudar de posição",
|
||||
"settings": "Configurações"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1344,7 +1344,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "",
|
||||
"remove": ""
|
||||
@@ -1364,46 +1364,6 @@
|
||||
"title": "",
|
||||
"message": ""
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Nume"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "",
|
||||
"edit": "",
|
||||
"remove": "",
|
||||
"moveUp": "Mută în sus",
|
||||
"moveDown": "Mută în jos",
|
||||
"createAbove": "",
|
||||
"createBelow": "",
|
||||
"openAllInNewTabs": ""
|
||||
},
|
||||
"create": {
|
||||
"title": "",
|
||||
"submit": ""
|
||||
},
|
||||
"remove": {
|
||||
"title": "",
|
||||
"message": ""
|
||||
},
|
||||
"edit": {
|
||||
"title": "",
|
||||
"submit": ""
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "",
|
||||
"changePosition": "Schimbă locația",
|
||||
"settings": "Setări"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1344,7 +1344,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Новый динамический элемент",
|
||||
"remove": "Удалить динамический элемент"
|
||||
@@ -1364,46 +1364,6 @@
|
||||
"title": "Удалить динамический элемент",
|
||||
"message": "Вы уверены, что хотите удалить этот динамический элемент? Содержимое будет перемещено в то же место в родительском элементе."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Название"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Новая категория",
|
||||
"edit": "Переименовать категорию",
|
||||
"remove": "Удалить категорию",
|
||||
"moveUp": "Переместить вверх",
|
||||
"moveDown": "Переместить вниз",
|
||||
"createAbove": "Создать категорию выше",
|
||||
"createBelow": "Создать категорию ниже",
|
||||
"openAllInNewTabs": "Открыть всё в новых вкладках"
|
||||
},
|
||||
"create": {
|
||||
"title": "Новая категория",
|
||||
"submit": "Добавить категорию"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Удалить категорию",
|
||||
"message": "Вы уверены, что хотите удалить категорию {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Переименовать категорию",
|
||||
"submit": "Переименовать категорию"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Новая категория",
|
||||
"changePosition": "Изменить позицию",
|
||||
"settings": "Настройки"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Открыть всё в новых вкладках",
|
||||
"text": "Некоторые браузеры могут блокировать массовое открытие вкладок из соображений безопасности. Homarr не смог открыть все окна, поскольку ваш браузер заблокировал это действие. Пожалуйста, разрешите \"Открывать всплывающие окна\" и повторите попытку."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1549,7 +1549,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nová dynamická sekcia",
|
||||
"remove": "Nová dynamická sekcia"
|
||||
@@ -1569,46 +1569,6 @@
|
||||
"title": "Nová dynamická sekcia",
|
||||
"message": "Naozaj chcete odstrániť túto dynamickú sekciu? Položky sa presunú na rovnaké miesto v nadradenej sekcii."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Názov"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Nová kategória",
|
||||
"edit": "Premenovať kategóriu",
|
||||
"remove": "Odstrániť kategóriu",
|
||||
"moveUp": "Posunúť nahor",
|
||||
"moveDown": "Posunúť nadol",
|
||||
"createAbove": "Nová kategória vyššie",
|
||||
"createBelow": "Nová kategória nižšie",
|
||||
"openAllInNewTabs": "Otvoriť všetko na kartách"
|
||||
},
|
||||
"create": {
|
||||
"title": "Nová kategória",
|
||||
"submit": "Pridať kategóriu"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Odstrániť kategóriu",
|
||||
"message": "Naozaj chcete odstrániť kategóriu {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Premenovať kategóriu",
|
||||
"submit": "Premenovať kategóriu"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Nová kategória",
|
||||
"changePosition": "Zmeniť pozíciu",
|
||||
"settings": "Nastavenia"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "Otvoriť všetko na kartách",
|
||||
"text": "Niektoré prehliadače môžu z bezpečnostných dôvodov blokovať hromadné otváranie kariet. Homarr nemohol otvoriť všetky okná, pretože váš prehliadač túto akciu zablokoval. Povoľte možnosť „Otvoriť kontextové okná“ a skúste to znova."
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
@@ -1344,7 +1344,7 @@
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"dynamic": {
|
||||
"container": {
|
||||
"action": {
|
||||
"create": "Nova dinamična vsebina",
|
||||
"remove": "Odstrani dinamični odsek"
|
||||
@@ -1364,46 +1364,6 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"field": {
|
||||
"name": {
|
||||
"label": "Ime"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "Nova kategorija",
|
||||
"edit": "Preimenuj kategorijo",
|
||||
"remove": "Odstrani kategorijo",
|
||||
"moveUp": "Premaknite se navzgor",
|
||||
"moveDown": "Premaknite se navzdol",
|
||||
"createAbove": "Nova kategorija zgoraj",
|
||||
"createBelow": "Nova kategorija spodaj",
|
||||
"openAllInNewTabs": ""
|
||||
},
|
||||
"create": {
|
||||
"title": "Nova kategorija",
|
||||
"submit": "Dodaj kategorijo"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Odstrani kategorijo",
|
||||
"message": "Ali ste prepričani, da želite odstraniti kategorijo {name}?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Preimenuj kategorijo",
|
||||
"submit": "Preimenuj kategorijo"
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"create": "Nova kategorija",
|
||||
"changePosition": "Spremeni položaj",
|
||||
"settings": "Nastavitve"
|
||||
}
|
||||
},
|
||||
"openAllInNewTabs": {
|
||||
"title": "",
|
||||
"text": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user