The widget options ActionIcon rendered inside the widget Card, whose containerType: size creates a stacking context, trapping its zIndex and letting the dnd-kit resize handles paint over and block it. Hoist BoardItemMenu out of the Card to the item root (same layer as the title badge) and raise it to zIndex 30, above the handles (20). Error recovery after edits is preserved via resetKeys=[item], which replaces the resetErrorBoundary prop the in-card menu used to receive.
150 lines
5.3 KiB
TypeScript
150 lines
5.3 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { ActionIcon, Menu } from "@mantine/core";
|
|
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
|
|
|
|
import { useSession } from "@homarr/auth/client";
|
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
|
import { useModalAction } from "@homarr/modals";
|
|
import { useSettings } from "@homarr/settings";
|
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
|
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
|
|
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
|
import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item";
|
|
import { useSectionContext } from "../sections/section-context";
|
|
import { useItemActions } from "./item-actions";
|
|
import { useOpenItemMoveModal } from "./item-move-modal";
|
|
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
|
|
|
interface BoardItemMenuProps {
|
|
item: SectionItem;
|
|
definition: WidgetDefinition;
|
|
resetErrorBoundary?: () => void;
|
|
}
|
|
|
|
export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
|
const { data: session } = useSession();
|
|
const [isEditMode] = useEditMode();
|
|
if (!session || !isEditMode) return null;
|
|
|
|
return <BoardItemMenuInner {...props} />;
|
|
};
|
|
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 openMoveModal = useOpenItemMoveModal();
|
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
|
useItemActions();
|
|
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(() => {
|
|
if (refResetErrorBoundaryOnNextRender.current) {
|
|
resetErrorBoundary?.();
|
|
refResetErrorBoundaryOnNextRender.current = false;
|
|
}
|
|
}, [item, resetErrorBoundary]);
|
|
|
|
const openEditModal = () => {
|
|
openModal(
|
|
{
|
|
kind: item.kind,
|
|
definition,
|
|
value: {
|
|
advancedOptions: item.advancedOptions,
|
|
options: item.options,
|
|
integrationIds: item.integrationIds,
|
|
},
|
|
onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => {
|
|
updateItemOptions({
|
|
itemId: item.id,
|
|
newOptions: options,
|
|
});
|
|
updateItemAdvancedOptions({
|
|
itemId: item.id,
|
|
newAdvancedOptions: advancedOptions,
|
|
});
|
|
updateItemIntegrations({
|
|
itemId: item.id,
|
|
newIntegrations: integrationIds,
|
|
});
|
|
refResetErrorBoundaryOnNextRender.current = true;
|
|
},
|
|
integrationData: integrationData.filter(
|
|
(integration) =>
|
|
"supportedIntegrations" in definition &&
|
|
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
|
),
|
|
integrationSupport: "supportedIntegrations" in definition,
|
|
settings,
|
|
itemId: item.id,
|
|
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
|
},
|
|
{
|
|
title(translate) {
|
|
return `${translate("item.edit.title")} - ${translate(`widget.${item.kind}.name`)}`;
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Menu withinPortal position="right-start" arrowPosition="center" opened={isMenuOpen} onChange={setIsMenuOpen}>
|
|
<Menu.Target>
|
|
<ActionIcon
|
|
variant="default"
|
|
size={24}
|
|
radius="sm"
|
|
pos="absolute"
|
|
top={12}
|
|
right={8}
|
|
style={{ zIndex: 30 }}
|
|
data-board-widget-settings
|
|
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
|
>
|
|
<IconDotsVertical size="1rem" />
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
<Menu.Dropdown miw={128}>
|
|
<Menu.Item
|
|
leftSection={<IconPencil size={16} />}
|
|
onClick={openEditModal}
|
|
onFocus={preloadWidgetEditModal}
|
|
onPointerEnter={preloadWidgetEditModal}
|
|
>
|
|
{tItem("action.edit")}
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={<IconLayoutKanban size={16} />}
|
|
onClick={() => {
|
|
openMoveModal({
|
|
entry: item,
|
|
sourceSectionId: section.id,
|
|
});
|
|
}}
|
|
>
|
|
{tItem("action.moveResize")}
|
|
</Menu.Item>
|
|
{canDuplicate && (
|
|
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
|
|
{tItem("action.duplicate")}
|
|
</Menu.Item>
|
|
)}
|
|
<Menu.Divider />
|
|
<BoardRemoveConfirmationMenuItem
|
|
label={tItem("action.remove")}
|
|
confirmationLabel={tItem("remove.message")}
|
|
onConfirm={() => removeItem({ itemId: item.id })}
|
|
/>
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
);
|
|
};
|