feat: rebuild board grid experience
This commit is contained in:
1 parent
5dc4dbeaef
commit
0110a2ef63
122 files changed
+9582
-4063
No files matched your search
@@ -1,10 +1,12 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetDefaultSizes } from "@homarr/definitions";
|
||||
|
||||
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getFirstEmptyPosition } from "./empty-position";
|
||||
import { getSectionElements } from "./section-elements";
|
||||
|
||||
@@ -19,17 +21,21 @@ export const createItemCallback =
|
||||
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const firstSection = previous.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.filter(
|
||||
(section): section is EmptySection =>
|
||||
section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) return previous;
|
||||
|
||||
const itemLayouts = createItemLayouts(previous, firstSection, kind);
|
||||
const widget = {
|
||||
id: id ?? createId(),
|
||||
kind,
|
||||
options,
|
||||
layouts: createItemLayouts(previous, firstSection, kind),
|
||||
layouts: itemLayouts,
|
||||
integrationIds,
|
||||
advancedOptions: {
|
||||
title: null,
|
||||
@@ -60,9 +66,14 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
|
||||
return layouts.map((layoutId) => {
|
||||
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
||||
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
||||
const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width;
|
||||
const size = {
|
||||
width: Math.min(itemSize.width, columnCount),
|
||||
height: itemSize.height,
|
||||
};
|
||||
|
||||
const emptyPosition = boardLayout
|
||||
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
|
||||
? getFirstEmptyPosition(elements, columnCount, undefined, size)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) {
|
||||
@@ -70,8 +81,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
|
||||
}
|
||||
|
||||
return {
|
||||
width: itemSize.width,
|
||||
height: itemSize.height,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
...emptyPosition,
|
||||
sectionId: currentSection.id,
|
||||
layoutId,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board, EmptySection, ItemLayout, Section } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getFirstEmptyPosition } from "./empty-position";
|
||||
import { getSectionElements } from "./section-elements";
|
||||
|
||||
@@ -15,16 +17,17 @@ export const duplicateItemCallback =
|
||||
if (!itemToDuplicate) return previous;
|
||||
|
||||
const clonedItem = structuredClone(itemToDuplicate);
|
||||
const clonedLayouts = clonedItem.layouts.map((layout) => ({
|
||||
...layout,
|
||||
...getNextPosition(previous, layout),
|
||||
}));
|
||||
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.concat({
|
||||
...clonedItem,
|
||||
id: createId(),
|
||||
layouts: clonedItem.layouts.map((layout) => ({
|
||||
...layout,
|
||||
...getNextPosition(previous, layout),
|
||||
})),
|
||||
layouts: clonedLayouts,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -42,8 +45,10 @@ const getNextPosition = (board: Board, layout: ItemLayout): { xOffset: number; y
|
||||
}
|
||||
|
||||
const firstSection = board.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) {
|
||||
@@ -67,18 +72,24 @@ const getEmptySectionPosition = (
|
||||
layout: ItemLayout,
|
||||
section: Section,
|
||||
): { xOffset: number; yOffset: number } | undefined => {
|
||||
const boardLayout = board.layouts.find((boardLayout) => boardLayout.id === layout.layoutId);
|
||||
const boardLayout = board.layouts.find((candidate) => candidate.id === layout.layoutId);
|
||||
if (!boardLayout) return;
|
||||
|
||||
const sectionElements = getSectionElements(board, { sectionId: layout.sectionId, layoutId: layout.layoutId });
|
||||
const sectionElements = getSectionElements(board, { sectionId: section.id, layoutId: layout.layoutId });
|
||||
if (section.kind !== "dynamic") {
|
||||
return getFirstEmptyPosition(sectionElements, boardLayout.columnCount, undefined, {
|
||||
const columnCount =
|
||||
section.kind === "empty"
|
||||
? getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset))
|
||||
: boardLayout.columnCount;
|
||||
|
||||
const size = {
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
});
|
||||
};
|
||||
return getFirstEmptyPosition(sectionElements, columnCount, undefined, size);
|
||||
}
|
||||
|
||||
const sectionLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layout.layoutId);
|
||||
const sectionLayout = section.layouts.find((candidate) => candidate.layoutId === layout.layoutId);
|
||||
if (!sectionLayout) return;
|
||||
|
||||
return getFirstEmptyPosition(sectionElements, sectionLayout.width, sectionLayout.height, {
|
||||
|
||||
@@ -7,7 +7,7 @@ export const getFirstEmptyPosition = (
|
||||
size: { width: number; height: number } = { width: 1, height: 1 },
|
||||
) => {
|
||||
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
|
||||
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
|
||||
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
|
||||
const isOccupied = elements.some(
|
||||
(element) =>
|
||||
element.yOffset < yOffset + size.height &&
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
@@ -10,6 +10,10 @@ import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||
|
||||
describe("item actions create-item", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("should add it to first section", () => {
|
||||
// Arrange
|
||||
const itemKind = "clock";
|
||||
@@ -52,7 +56,7 @@ describe("item actions create-item", () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, 9999, { height: 1, width: 1 });
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
|
||||
});
|
||||
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
|
||||
// Arrange
|
||||
@@ -94,8 +98,10 @@ describe("item actions create-item", () => {
|
||||
|
||||
// Assert
|
||||
expect(result.items.length).toBe(3);
|
||||
const item = result.items.find((item) => item.id !== itemInFirstSection.id && item.id !== otherItem.id);
|
||||
expect(item).toEqual(
|
||||
const createdItem = result.items.find(
|
||||
(candidate) => candidate.id !== itemInFirstSection.id && candidate.id !== otherItem.id,
|
||||
);
|
||||
expect(createdItem).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: itemKind,
|
||||
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
|
||||
@@ -104,8 +110,35 @@ describe("item actions create-item", () => {
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
|
||||
layout.columnCount,
|
||||
9999,
|
||||
undefined,
|
||||
{ height: 1, width: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test("clamps wide defaults and places below a full automatic canvas", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 1;
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "existing" })
|
||||
.addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 })
|
||||
.build(),
|
||||
);
|
||||
|
||||
const result = createItemCallback({ id: "created", kind: "mediaMissing" })(board);
|
||||
const created = result.items.find((item) => item.id === "created");
|
||||
|
||||
expect(created?.layouts).toEqual([
|
||||
expect.objectContaining({
|
||||
layoutId: layout.id,
|
||||
sectionId: "canvas",
|
||||
width: 1,
|
||||
height: 3,
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { duplicateItemCallback } from "../duplicate-item";
|
||||
import * as emptyPositionModule from "../empty-position";
|
||||
@@ -7,6 +7,10 @@ import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||
|
||||
describe("item actions duplicate-item", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("should copy it in the same section", () => {
|
||||
// Arrange
|
||||
const itemKind = "minecraftServerStatus";
|
||||
@@ -61,4 +65,28 @@ describe("item actions duplicate-item", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the next automatic canvas row when it is the first available space", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 1;
|
||||
const currentItem = new ItemMockBuilder({ id: "source" })
|
||||
.addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 })
|
||||
.build();
|
||||
board.items.push(currentItem);
|
||||
|
||||
const result = duplicateItemCallback({ itemId: currentItem.id })(board);
|
||||
const duplicated = result.items.find((item) => item.id !== currentItem.id);
|
||||
|
||||
expect(duplicated?.layouts[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
layoutId: layout.id,
|
||||
sectionId: "canvas",
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
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 { EmptySectionMockBuilder } from "./mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
|
||||
describe("item move targets", () => {
|
||||
test("offers the canvas, enabled gutters, and nested sections", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected the board mock to contain a layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 6;
|
||||
layout.leftGutterColumnCount = 1;
|
||||
layout.rightGutterColumnCount = 1;
|
||||
|
||||
board.sections.push(
|
||||
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",
|
||||
options: {
|
||||
title: "",
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
showLabel: true,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
},
|
||||
})
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
parentSectionId: "canvas",
|
||||
width: 2,
|
||||
height: 2,
|
||||
})
|
||||
.build(),
|
||||
);
|
||||
|
||||
const targets = getMoveTargets(
|
||||
board,
|
||||
layout.id,
|
||||
{
|
||||
id: "weather",
|
||||
type: "item",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
canvas: "Dashboard canvas",
|
||||
dynamicSection: "New section",
|
||||
leftRail: "Left dashboard rail",
|
||||
rightRail: "Right dashboard rail",
|
||||
numbered: (name, index) => `${name}, destination ${index}`,
|
||||
located: (name, location, index) => `${name}, ${location}, destination ${index}`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(targets.map((target) => target.label)).toEqual([
|
||||
"Dashboard canvas, destination 1",
|
||||
"Left dashboard rail, destination 2",
|
||||
"Right dashboard rail, destination 3",
|
||||
"New section, destination 4",
|
||||
]);
|
||||
expect(new Set(targets.map((target) => target.label))).toHaveLength(targets.length);
|
||||
});
|
||||
|
||||
test("does not offer canvases that are narrower than a nested section's contents", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected the board mock to contain a layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 5;
|
||||
layout.leftGutterColumnCount = 2;
|
||||
|
||||
const section = new DynamicSectionMockBuilder({ id: "source" })
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
parentSectionId: "canvas",
|
||||
width: 4,
|
||||
height: 2,
|
||||
})
|
||||
.build();
|
||||
board.sections.push(
|
||||
new EmptySectionMockBuilder({ id: "canvas" }).build(),
|
||||
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
|
||||
section,
|
||||
);
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "nested" })
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
sectionId: section.id,
|
||||
xOffset: 2,
|
||||
width: 2,
|
||||
})
|
||||
.build(),
|
||||
);
|
||||
|
||||
const sourceLayout = section.layouts[0];
|
||||
if (!sourceLayout) throw new Error("Expected the source section to contain a layout");
|
||||
const targets = getMoveTargets(
|
||||
board,
|
||||
layout.id,
|
||||
{ ...sourceLayout, id: section.id, type: "section" },
|
||||
{
|
||||
canvas: "Canvas",
|
||||
dynamicSection: "Section",
|
||||
leftRail: "Left",
|
||||
rightRail: "Right",
|
||||
numbered: (name, index) => `${name} ${index}`,
|
||||
located: (name, location, index) => `${name} ${location} ${index}`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(targets).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,8 @@ export class BoardMockBuilder {
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export class CategorySectionMockBuilder {
|
||||
private readonly section: CategorySection;
|
||||
|
||||
constructor(section?: Partial<CategorySection>) {
|
||||
this.section = {
|
||||
id: createId(),
|
||||
kind: "category",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
name: "Category",
|
||||
collapsed: false,
|
||||
...section,
|
||||
} satisfies CategorySection;
|
||||
}
|
||||
|
||||
public build(): CategorySection {
|
||||
return this.section;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,23 @@
|
||||
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?: Partial<DynamicSection>) {
|
||||
constructor(section?: DynamicSectionMock) {
|
||||
this.section = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
options: {
|
||||
title: "",
|
||||
borderColor: "",
|
||||
customCssClasses: [],
|
||||
},
|
||||
layouts: [],
|
||||
...section,
|
||||
collapsed: section?.collapsed ?? false,
|
||||
options: dynamicSectionOptionsSchema.parse(section?.options),
|
||||
} satisfies DynamicSection;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ export class LayoutMockBuilder {
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
...layout,
|
||||
} satisfies Board["layouts"][0];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useRef } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Badge, Card } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -17,9 +18,12 @@ import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
import { WidgetContextMenu } from "./widget-context-menu";
|
||||
|
||||
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: SectionItem;
|
||||
}
|
||||
@@ -40,6 +44,9 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
|
||||
<Card
|
||||
ref={ref}
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-grid-item-content
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
@@ -117,7 +124,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
||||
{isEditMode && <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />}
|
||||
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</>
|
||||
)}
|
||||
@@ -130,7 +137,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||
}
|
||||
/>
|
||||
<BoardItemMenu offset={4} item={newItem} />
|
||||
{isEditMode && <BoardItemMenu offset={4} item={newItem} />}
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
@@ -13,11 +12,10 @@ import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
|
||||
import { useOpenItemMoveModal } from "./item-move-modal";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
|
||||
interface BoardItemMenuProps {
|
||||
offset: number;
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
resetErrorBoundary?: () => void;
|
||||
@@ -31,25 +29,21 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
||||
return <BoardItemMenuInner {...props} />;
|
||||
};
|
||||
|
||||
const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
const tItem = useScopedI18n("item");
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const hasSupportedIntegrations =
|
||||
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: isMenuOpen && hasSupportedIntegrations,
|
||||
});
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const { integrations: integrationData = [], section } = useSectionContext();
|
||||
const settings = useSettings();
|
||||
const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
|
||||
// Reset error boundary on next render if item has been edited
|
||||
useEffect(() => {
|
||||
@@ -84,7 +78,7 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
|
||||
});
|
||||
refResetErrorBoundaryOnNextRender.current = true;
|
||||
},
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
integrationData: integrationData.filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in definition &&
|
||||
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
@@ -95,8 +89,8 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
{
|
||||
title(t) {
|
||||
return `${t("item.edit.title")} - ${t(`widget.${item.kind}.name`)}`;
|
||||
title(translate) {
|
||||
return `${translate("item.edit.title")} - ${translate(`widget.${item.kind}.name`)}`;
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -117,14 +111,16 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius={"xl"}
|
||||
size={24}
|
||||
radius="sm"
|
||||
pos="absolute"
|
||||
top={offset}
|
||||
right={offset}
|
||||
top={12}
|
||||
right={8}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
data-board-widget-settings
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
<IconDotsVertical size="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
@@ -134,24 +130,25 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
|
||||
onClick={openEditModal}
|
||||
onFocus={preloadWidgetEditModal}
|
||||
onPointerEnter={preloadWidgetEditModal}
|
||||
disabled={hasSupportedIntegrations && isPending}
|
||||
>
|
||||
{tItem("action.edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onFocus={preloadItemMoveModal}
|
||||
onPointerEnter={preloadItemMoveModal}
|
||||
onClick={() => {
|
||||
if (!gridstack.current) return;
|
||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||
openMoveModal({
|
||||
entry: item,
|
||||
sourceSectionId: section.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
{canDuplicate && (
|
||||
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
|
||||
{tItem("action.duplicate")}
|
||||
</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}>
|
||||
|
||||
@@ -1,94 +1,220 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Grid, NumberInput, Stack } from "@mantine/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Grid, NumberInput, Select, Stack } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { GridStack } from "@homarr/gridstack";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { getLayoutRowCount } from "../layout";
|
||||
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
|
||||
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
|
||||
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
|
||||
|
||||
export interface ItemMoveModalProps {
|
||||
gridStack: GridStack;
|
||||
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
|
||||
columnCount: number;
|
||||
export type MovableEntry = Pick<
|
||||
SectionItem | ContainerSectionItem,
|
||||
"id" | "type" | "width" | "height" | "xOffset" | "yOffset"
|
||||
>;
|
||||
|
||||
export interface OpenItemMoveModalInput {
|
||||
entry: MovableEntry;
|
||||
sourceSectionId: string;
|
||||
}
|
||||
|
||||
export const ItemMoveModal = createModal<ItemMoveModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
|
||||
interface InnerProps extends OpenItemMoveModalInput {
|
||||
board: Board;
|
||||
currentLayoutId: string;
|
||||
commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void;
|
||||
}
|
||||
|
||||
interface MoveTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
}
|
||||
|
||||
interface MoveTargetLabels {
|
||||
canvas: string;
|
||||
container: string;
|
||||
leftRail: string;
|
||||
rightRail: string;
|
||||
numbered: (name: string, index: number) => string;
|
||||
located: (name: string, location: string, index: number) => string;
|
||||
}
|
||||
|
||||
type MoveTargetCandidate = Omit<MoveTarget, "label"> & {
|
||||
name: string;
|
||||
location?: string;
|
||||
};
|
||||
|
||||
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const tLandmark = useScopedI18n("board.landmark");
|
||||
const tContainer = useScopedI18n("section.container");
|
||||
const tMoveResize = useScopedI18n("item.moveResize");
|
||||
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
|
||||
const minimumSize = useMemo(
|
||||
() => getEntryMinimumSize(board, currentLayoutId, entry),
|
||||
[board, currentLayoutId, entry],
|
||||
);
|
||||
const targets = useMemo(
|
||||
() =>
|
||||
getMoveTargets(board, currentLayoutId, entry, {
|
||||
canvas: tLandmark("canvas"),
|
||||
container: tContainer("action.create"),
|
||||
leftRail: tLandmark("leftRail"),
|
||||
rightRail: tLandmark("rightRail"),
|
||||
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
|
||||
located: (name, location, index) =>
|
||||
tMoveResize("target.located", {
|
||||
name,
|
||||
location,
|
||||
index: String(index),
|
||||
}),
|
||||
}),
|
||||
[board, currentLayoutId, entry, tContainer, tLandmark, tMoveResize],
|
||||
);
|
||||
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
|
||||
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
|
||||
|
||||
if (!initialTarget) {
|
||||
throw new Error("No valid dashboard section is available for this item");
|
||||
}
|
||||
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
xOffset: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(innerProps.columnCount - 1),
|
||||
yOffset: z.number().min(0),
|
||||
width: z.number().min(1).max(maxWidthRef.current),
|
||||
height: z.number().min(1),
|
||||
sectionId: z.string().min(1),
|
||||
xOffset: z.number().int().min(0).max(32767),
|
||||
yOffset: z.number().int().min(0).max(32767),
|
||||
width: z.number().int().min(1).max(32767),
|
||||
height: z.number().int().min(1).max(32767),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
xOffset: innerProps.item.xOffset,
|
||||
yOffset: innerProps.item.yOffset,
|
||||
width: innerProps.item.width,
|
||||
height: innerProps.item.height,
|
||||
},
|
||||
onValuesChange(values, previous) {
|
||||
if (values.xOffset !== previous.xOffset) {
|
||||
maxWidthRef.current = innerProps.columnCount - values.xOffset;
|
||||
}
|
||||
sectionId: initialTarget.id,
|
||||
xOffset: entry.xOffset,
|
||||
yOffset: entry.yOffset,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
},
|
||||
},
|
||||
);
|
||||
const selectedTarget = targetById.get(form.values.sectionId) ?? initialTarget;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: Pick<Item["layouts"][number], "height" | "width" | "xOffset" | "yOffset">) => {
|
||||
const gridItem = innerProps.gridStack
|
||||
.getGridItems()
|
||||
.find((item) => item.getAttribute("data-id") === innerProps.item.id);
|
||||
if (!gridItem) return;
|
||||
innerProps.gridStack.update(gridItem, {
|
||||
h: values.height,
|
||||
w: values.width,
|
||||
x: values.xOffset,
|
||||
(values: { sectionId: string; xOffset: number; yOffset: number; width: number; height: number }) => {
|
||||
const target = targetById.get(values.sectionId);
|
||||
if (!target) return;
|
||||
|
||||
const width = Math.max(minimumSize.width, Math.min(values.width, target.columnCount));
|
||||
const height = Math.max(minimumSize.height, values.height);
|
||||
const nextPlacement: SectionGridPlacement = {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
x: Math.min(values.xOffset, target.columnCount - width),
|
||||
y: values.yOffset,
|
||||
});
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
const targetPlacements = getSectionGridPlacements(board, currentLayoutId, target.id).filter(
|
||||
(placement) => placement.id !== entry.id,
|
||||
);
|
||||
const resolvedTarget = resolvePinnedGridCollisions(
|
||||
[...targetPlacements, nextPlacement],
|
||||
nextPlacement,
|
||||
target.columnCount,
|
||||
);
|
||||
if (target.maxRowCount !== null && getLayoutRowCount(resolvedTarget) > target.maxRowCount) {
|
||||
form.setFieldError("height", tMoveResize("keyboard.boundary"));
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshots =
|
||||
sourceSectionId === target.id
|
||||
? [{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget }]
|
||||
: [
|
||||
{
|
||||
layoutId: currentLayoutId,
|
||||
sectionId: sourceSectionId,
|
||||
placements: getSectionGridPlacements(board, currentLayoutId, sourceSectionId).filter(
|
||||
(placement) => placement.id !== entry.id,
|
||||
),
|
||||
},
|
||||
{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget },
|
||||
];
|
||||
|
||||
commitSectionGrids(snapshots);
|
||||
actions.closeModal();
|
||||
focusMovedEntry(entry.id, target.id);
|
||||
},
|
||||
[actions, innerProps.gridStack, innerProps.item.id],
|
||||
[
|
||||
actions,
|
||||
board,
|
||||
commitSectionGrids,
|
||||
currentLayoutId,
|
||||
entry,
|
||||
form,
|
||||
minimumSize,
|
||||
sourceSectionId,
|
||||
targetById,
|
||||
tMoveResize,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit, console.error)}>
|
||||
<Stack>
|
||||
<Select
|
||||
data={targets.map((target) => ({ value: target.id, label: target.label }))}
|
||||
label={tMoveResize("field.section.label")}
|
||||
data-autofocus
|
||||
allowDeselect={false}
|
||||
searchable
|
||||
{...form.getInputProps("sectionId")}
|
||||
/>
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.xOffset.label")}
|
||||
data-autofocus
|
||||
label={tMoveResize("field.xOffset.label")}
|
||||
min={0}
|
||||
max={innerProps.columnCount - 1}
|
||||
max={selectedTarget.columnCount - 1}
|
||||
{...form.getInputProps("xOffset")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput label={t("item.moveResize.field.yOffset.label")} min={0} {...form.getInputProps("yOffset")} />
|
||||
<NumberInput
|
||||
label={tMoveResize("field.yOffset.label")}
|
||||
min={0}
|
||||
max={selectedTarget.maxRowCount === null ? undefined : selectedTarget.maxRowCount - 1}
|
||||
{...form.getInputProps("yOffset")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.width.label")}
|
||||
min={1}
|
||||
max={innerProps.columnCount - form.values.xOffset}
|
||||
label={tMoveResize("field.width.label")}
|
||||
min={minimumSize.width}
|
||||
max={selectedTarget.columnCount - Math.min(form.values.xOffset, selectedTarget.columnCount - 1)}
|
||||
{...form.getInputProps("width")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput label={t("item.moveResize.field.height.label")} min={1} {...form.getInputProps("height")} />
|
||||
<NumberInput
|
||||
label={tMoveResize("field.height.label")}
|
||||
min={minimumSize.height}
|
||||
max={
|
||||
selectedTarget.maxRowCount === null
|
||||
? undefined
|
||||
: selectedTarget.maxRowCount - Math.min(form.values.yOffset, selectedTarget.maxRowCount - 1)
|
||||
}
|
||||
{...form.getInputProps("height")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<ModalFormFooter onCancel={actions.closeModal} />
|
||||
@@ -101,3 +227,137 @@ export const ItemMoveModal = createModal<ItemMoveModalProps>(({ actions, innerPr
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
|
||||
export const useOpenItemMoveModal = () => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { commitSectionGrids } = useGridLayoutActions();
|
||||
const { openModal } = useModalAction(ItemMoveModal);
|
||||
|
||||
return useCallback(
|
||||
(input: OpenItemMoveModalInput) => {
|
||||
openModal({
|
||||
...input,
|
||||
board,
|
||||
currentLayoutId,
|
||||
commitSectionGrids,
|
||||
});
|
||||
},
|
||||
[board, commitSectionGrids, currentLayoutId, openModal],
|
||||
);
|
||||
};
|
||||
|
||||
export const getMoveTargets = (
|
||||
board: Board,
|
||||
layoutId: string,
|
||||
entry: MovableEntry,
|
||||
labels: MoveTargetLabels,
|
||||
): MoveTarget[] => {
|
||||
const minimum = getEntryMinimumSize(board, layoutId, entry);
|
||||
|
||||
const candidates = board.sections.flatMap((section): MoveTargetCandidate[] => {
|
||||
if (section.kind === "empty") {
|
||||
const columnCount = getSectionColumnCount(board, layoutId, section.id);
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
if (columnCount === 0) return [];
|
||||
return minimum.width <= columnCount
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
|
||||
columnCount,
|
||||
maxRowCount: getSectionMaxRowCount(board, layoutId, section.id),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) return [];
|
||||
if (
|
||||
(entry.type === "section" && isSameOrDescendant(board, layoutId, section.id, entry.id)) ||
|
||||
minimum.width > layout.width ||
|
||||
minimum.height > layout.height
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: section.id,
|
||||
name: section.options.title || labels.container,
|
||||
columnCount: layout.width,
|
||||
maxRowCount: layout.height,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return candidates.map(({ name, location, ...target }, index) => ({
|
||||
...target,
|
||||
label: location ? labels.located(name, location, index + 1) : labels.numbered(name, index + 1),
|
||||
}));
|
||||
};
|
||||
|
||||
const getSectionColumnCount = (board: Board, layoutId: string, sectionId: string) => {
|
||||
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
|
||||
if (!layout) throw new Error(`Layout "${layoutId}" was not found`);
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) throw new Error(`Section "${sectionId}" was not found`);
|
||||
|
||||
const columnCount = getSectionGridColumnCount(board, layoutId, sectionId);
|
||||
if (columnCount === null) throw new Error(`Section "${sectionId}" has no current layout`);
|
||||
return columnCount;
|
||||
};
|
||||
|
||||
const getSectionMaxRowCount = (board: Board, layoutId: string, sectionId: string) => {
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) return null;
|
||||
if (section.kind === "container") {
|
||||
return section.layouts.find((candidate) => candidate.layoutId === layoutId)?.height ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getEntryMinimumSize = (board: Board, layoutId: string, entry: MovableEntry) => {
|
||||
if (entry.type === "item") return { width: 1, height: 1 };
|
||||
const children = getSectionGridPlacements(board, layoutId, entry.id);
|
||||
return {
|
||||
width: Math.max(1, ...children.map((child) => child.x + child.w)),
|
||||
height: Math.max(1, ...children.map((child) => child.y + child.h)),
|
||||
};
|
||||
};
|
||||
|
||||
const isSameOrDescendant = (board: Board, layoutId: string, candidateId: string, ancestorId: string) => {
|
||||
let current: string | undefined = candidateId;
|
||||
const visited = new Set<string>();
|
||||
while (current && !visited.has(current)) {
|
||||
if (current === ancestorId) return true;
|
||||
visited.add(current);
|
||||
const section = board.sections.find((candidate) => candidate.id === current && candidate.kind === "container");
|
||||
if (!section || section.kind !== "container") return false;
|
||||
current = section.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const focusMovedEntry = (entryId: string, sectionId: string) => {
|
||||
let attempts = 0;
|
||||
const tryFocus = () => {
|
||||
const section = Array.from(document.querySelectorAll<HTMLElement>("[data-grid-section-id]")).find(
|
||||
(candidate) => candidate.dataset.gridSectionId === sectionId,
|
||||
);
|
||||
const entry = Array.from(section?.querySelectorAll<HTMLElement>(":scope > [data-grid-item-id]") ?? []).find(
|
||||
(candidate) => candidate.dataset.gridItemId === entryId,
|
||||
);
|
||||
const editorEntry = entry?.querySelector<HTMLElement>("[data-editor-grid-entry]");
|
||||
if (editorEntry) {
|
||||
editorEntry.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
if (attempts < 10) requestAnimationFrame(tryFocus);
|
||||
};
|
||||
|
||||
requestAnimationFrame(tryFocus);
|
||||
};
|
||||
@@ -1,53 +1,68 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
||||
import { createId } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
import classes from "./item-select-modal.module.css";
|
||||
|
||||
export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, innerProps }) => {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
interface ItemSelectModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
integrationData: RouterOutputs["integration"]["all"] | undefined;
|
||||
customWidgetDefs: RouterOutputs["customWidget"]["available"] | undefined;
|
||||
ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const ItemSelectModalContent = ({
|
||||
actions,
|
||||
integrationData,
|
||||
customWidgetDefs,
|
||||
ensureIntegrationDataAsync,
|
||||
isAdmin,
|
||||
}: ItemSelectModalContentProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||
const selectionLock = useRef(false);
|
||||
const t = useI18n();
|
||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery(
|
||||
{ boardId: innerProps.boardId },
|
||||
{ enabled: isAdmin },
|
||||
);
|
||||
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const settings = useSettings();
|
||||
|
||||
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
objectEntries(widgetImports)
|
||||
.filter(([kind]) => kind !== "customApi")
|
||||
.map(([kind, value]) => ({
|
||||
kind,
|
||||
supportedIntegrations:
|
||||
"supportedIntegrations" in value.definition
|
||||
? value.definition.supportedIntegrations.filter((integration) => integration !== "mock")
|
||||
: [],
|
||||
icon: value.definition.icon,
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
}))
|
||||
widgetKinds
|
||||
.filter((kind) => kind !== "customApi")
|
||||
.map((kind) => {
|
||||
return {
|
||||
kind,
|
||||
supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter(
|
||||
(integration) => integration !== "mock",
|
||||
),
|
||||
icon: widgetCatalogIcons[kind],
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
};
|
||||
})
|
||||
.sort((itemA, itemB) => {
|
||||
if (itemA.kind === "app") return -1;
|
||||
if (itemB.kind === "app") return 1;
|
||||
@@ -72,77 +87,108 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
|
||||
[customWidgetDefs, search],
|
||||
);
|
||||
|
||||
const handleAddCustomWidget = (definition: NonNullable<typeof customWidgetDefs>[number]) => {
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
|
||||
const options = {
|
||||
...defaultOptions,
|
||||
definitionId: definition.id,
|
||||
configuration: definition.defaultOptions,
|
||||
configurationVersion: definition.updatedAt.getTime(),
|
||||
};
|
||||
actions.closeModal();
|
||||
openEditModal(
|
||||
{
|
||||
kind: "customApi",
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options,
|
||||
integrationIds: [],
|
||||
},
|
||||
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: configuredOptions });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
},
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${definition.name}`,
|
||||
},
|
||||
);
|
||||
const notifyDefinitionLoadError = (error: unknown) => {
|
||||
showErrorNotification({
|
||||
title: t("common.error"),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
};
|
||||
|
||||
const handleAdd = (kind: WidgetKind) => {
|
||||
const definition = widgetImports[kind].definition;
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
|
||||
const matchingIntegrations = hasIntegrationSupport
|
||||
? (integrationData ?? []).filter((integration) =>
|
||||
(definition.supportedIntegrations as string[]).includes(integration.kind),
|
||||
)
|
||||
: [];
|
||||
|
||||
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
|
||||
|
||||
createItem({ id: itemId, kind, integrationIds });
|
||||
actions.closeModal();
|
||||
|
||||
openEditModal(
|
||||
{
|
||||
kind,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options: defaultOptions,
|
||||
integrationIds,
|
||||
const handleAddCustomWidget = async (customWidgetDefinition: NonNullable<typeof customWidgetDefs>[number]) => {
|
||||
if (!tryLockSelection(selectionLock)) return;
|
||||
setLoadingSelection(`custom:${customWidgetDefinition.id}`);
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
const definition = await loadWidgetDefinition("customApi");
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
const options = {
|
||||
...defaultOptions,
|
||||
definitionId: customWidgetDefinition.id,
|
||||
configuration: customWidgetDefinition.defaultOptions,
|
||||
configurationVersion: customWidgetDefinition.updatedAt.getTime(),
|
||||
};
|
||||
actions.closeModal();
|
||||
openEditModal(
|
||||
{
|
||||
kind: "customApi",
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options,
|
||||
integrationIds: [],
|
||||
},
|
||||
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: configuredOptions });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
},
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
settings,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
updateItemOptions({ itemId, newOptions: options });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
|
||||
},
|
||||
integrationData: matchingIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||
},
|
||||
);
|
||||
);
|
||||
} catch (error) {
|
||||
notifyDefinitionLoadError(error);
|
||||
} finally {
|
||||
unlockSelection(selectionLock);
|
||||
setLoadingSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (kind: WidgetKind) => {
|
||||
if (!tryLockSelection(selectionLock)) return;
|
||||
setLoadingSelection(kind);
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
const definition = await loadWidgetDefinition(kind);
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
|
||||
const matchingIntegrations = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport,
|
||||
supportedIntegrations: definition.supportedIntegrations ?? [],
|
||||
currentData: integrationData,
|
||||
ensureDataAsync: ensureIntegrationDataAsync,
|
||||
});
|
||||
|
||||
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
|
||||
createItem({ id: itemId, kind, integrationIds });
|
||||
actions.closeModal();
|
||||
|
||||
openEditModal(
|
||||
{
|
||||
kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options: defaultOptions,
|
||||
integrationIds,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
updateItemOptions({ itemId, newOptions: options });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
||||
},
|
||||
integrationData: matchingIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
notifyDefinitionLoadError(error);
|
||||
} finally {
|
||||
unlockSelection(selectionLock);
|
||||
setLoadingSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -151,9 +197,9 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredItems.length === 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
handleAdd(filteredItems[0]!.kind);
|
||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||
const [item] = filteredItems;
|
||||
if (item) void handleAdd(item.kind);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -161,7 +207,13 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
|
||||
<WidgetItem
|
||||
key={item.kind}
|
||||
item={item}
|
||||
onSelect={() => handleAdd(item.kind)}
|
||||
onSelect={() => void handleAdd(item.kind)}
|
||||
onIntent={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === item.kind}
|
||||
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
|
||||
/>
|
||||
))}
|
||||
@@ -177,12 +229,12 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<Card
|
||||
key={def.id}
|
||||
className={classes.card}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||
style={{ overflow: "hidden" }}
|
||||
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
@@ -200,18 +252,24 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
className={classes.action}
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
opacity: "var(--_hover-opacity)",
|
||||
transition: "opacity 150ms ease",
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => handleAddCustomWidget(def)} variant="light" size="xs" fullWidth>
|
||||
<Button
|
||||
onClick={() => void handleAddCustomWidget(def)}
|
||||
variant="light"
|
||||
size="xs"
|
||||
fullWidth
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === `custom:${def.id}`}
|
||||
>
|
||||
{t("item.create.addToBoard")}
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -231,7 +289,38 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
);
|
||||
}).withOptions({
|
||||
};
|
||||
|
||||
const ItemSelectModalFrame = ({
|
||||
actions,
|
||||
innerProps,
|
||||
}: {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: { boardId: string };
|
||||
}) => {
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery(
|
||||
{ boardId: innerProps.boardId },
|
||||
{ enabled: isAdmin },
|
||||
);
|
||||
|
||||
return (
|
||||
<ItemSelectModalContent
|
||||
actions={actions}
|
||||
integrationData={integrationData}
|
||||
customWidgetDefs={customWidgetDefs}
|
||||
ensureIntegrationDataAsync={() => utils.integration.all.ensureData()}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ItemSelectModal = createModal<{ boardId: string }>((props) => (
|
||||
<ItemSelectModalFrame {...props} />
|
||||
)).withOptions({
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
size: modalSizeSelect,
|
||||
});
|
||||
@@ -239,6 +328,9 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne
|
||||
const WidgetItem = ({
|
||||
item,
|
||||
onSelect,
|
||||
onIntent,
|
||||
disabled,
|
||||
loading,
|
||||
hasMatchingIntegration,
|
||||
}: {
|
||||
item: {
|
||||
@@ -249,23 +341,27 @@ const WidgetItem = ({
|
||||
icon: TablerIcon;
|
||||
};
|
||||
onSelect: () => void;
|
||||
onIntent: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
hasMatchingIntegration: boolean;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={classes.card}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
"--_hover-opacity": "0",
|
||||
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
|
||||
borderWidth: hasMatchingIntegration ? 2 : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||
onFocus={onIntent}
|
||||
onPointerEnter={onIntent}
|
||||
aria-busy={loading || undefined}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
@@ -282,18 +378,17 @@ const WidgetItem = ({
|
||||
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
||||
</Stack>
|
||||
<Box
|
||||
className={classes.action}
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
opacity: "var(--_hover-opacity)",
|
||||
transition: "opacity 150ms ease",
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth disabled={disabled} loading={loading}>
|
||||
{t(`item.create.addToBoard`)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -3,15 +3,7 @@
|
||||
import type { MutableRefObject, ReactNode } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCircleCheck,
|
||||
IconCopy,
|
||||
IconLayoutKanban,
|
||||
IconRefresh,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconAlertTriangle, IconCircleCheck, IconRefresh, IconSettings } from "@tabler/icons-react";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
@@ -20,7 +12,7 @@ import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -30,9 +22,7 @@ import { getWidgetQueryKeys } from "@homarr/widgets/definition";
|
||||
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
|
||||
interface WidgetContextMenuProps {
|
||||
@@ -44,9 +34,10 @@ interface WidgetContextMenuProps {
|
||||
|
||||
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const [isEditMode] = useEditMode();
|
||||
const settings = useSettings();
|
||||
|
||||
if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>;
|
||||
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
|
||||
|
||||
return (
|
||||
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
|
||||
@@ -94,25 +85,20 @@ type WidgetContextMenuDropdownProps = Omit<WidgetContextMenuProps, "children"> &
|
||||
};
|
||||
|
||||
const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => {
|
||||
const { data: session } = useSession();
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const tItem = useScopedI18n("item");
|
||||
const tMenu = useScopedI18n("item.menu.label");
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const canConfigureWidget = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const hasSupportedIntegrations =
|
||||
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: hasSupportedIntegrations,
|
||||
});
|
||||
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
||||
@@ -188,7 +174,7 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
|
||||
);
|
||||
}
|
||||
},
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
integrationData: integrationData.filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in definition &&
|
||||
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
@@ -266,35 +252,8 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEditMode && (
|
||||
<>
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onFocus={preloadItemMoveModal}
|
||||
onPointerEnter={preloadItemMoveModal}
|
||||
onClick={() => {
|
||||
if (!gridstack.current) return;
|
||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||
}}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
{canConfigureWidget && (
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={() => duplicateItem({ itemId: item.id })}
|
||||
>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<>
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
||||
onClick={handleRefetch}
|
||||
@@ -316,31 +275,11 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
|
||||
onClick={openEditModal}
|
||||
onFocus={preloadWidgetEditModal}
|
||||
onPointerEnter={preloadWidgetEditModal}
|
||||
disabled={hasSupportedIntegrations && isPending}
|
||||
disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)}
|
||||
>
|
||||
{tMenu("settings")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
|
||||
{isEditMode && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => {
|
||||
openConfirmModal({
|
||||
title: tItem("remove.title"),
|
||||
children: tItem("remove.message"),
|
||||
onConfirm: () => removeItem({ itemId: item.id }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tItem("action.remove")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user