Toggle switches and edit modal changes from the context menu now call saveBoard mutation to persist to DB. Previously only the client cache was updated, losing changes on reload. Removes unnecessary invalidation that caused cascading refetch loops.
247 lines
9.3 KiB
TypeScript
247 lines
9.3 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo } from "react";
|
|
import { Group, Menu, Switch } from "@mantine/core";
|
|
import { IconCopy, IconLayoutKanban, IconSettings, IconTrash } from "@tabler/icons-react";
|
|
|
|
import { clientApi } from "@homarr/api/client";
|
|
import { useSession } from "@homarr/auth/client";
|
|
import { useRequiredBoard } from "@homarr/boards/context";
|
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
|
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
|
import { useSettings } from "@homarr/settings";
|
|
import { translateIfNecessary } from "@homarr/translation";
|
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
|
import type { WidgetContextMenuAction } from "@homarr/widgets";
|
|
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
|
import { WidgetEditModal } from "@homarr/widgets/modals";
|
|
|
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
|
import { useSectionContext } from "../sections/section-context";
|
|
import { useItemActions } from "./item-actions";
|
|
import { ItemMoveModal } from "./item-move-modal";
|
|
|
|
interface WidgetContextMenuProps {
|
|
item: SectionItem;
|
|
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => {
|
|
const { data: session } = useSession();
|
|
const [isEditMode] = useEditMode();
|
|
const board = useRequiredBoard();
|
|
const tItem = useScopedI18n("item");
|
|
const tMenu = useScopedI18n("item.menu.label");
|
|
const t = useI18n();
|
|
const settings = useSettings();
|
|
const { openModal } = useModalAction(WidgetEditModal);
|
|
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
|
const { openConfirmModal } = useConfirmModal();
|
|
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
|
useItemActions();
|
|
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
|
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
|
|
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
|
const { gridstack } = useSectionContext().refs;
|
|
|
|
const options = useMemo(
|
|
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
|
|
[item.kind, settings, item.options],
|
|
);
|
|
|
|
type OptionDef = { type: string; skipContextMenu?: boolean };
|
|
const toggleOptions = useMemo(() => {
|
|
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
|
|
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
|
|
}, [currentDefinition, settings]);
|
|
|
|
const widgetContextActions = useMemo(() => {
|
|
const def = currentDefinition as Record<string, unknown>;
|
|
if (typeof def.contextActions !== "function") return [];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const actions = (def.contextActions as any)({
|
|
options,
|
|
setOptions: (partial: Record<string, unknown>) => {
|
|
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
|
|
},
|
|
integrationIds: item.integrationIds,
|
|
context: { isEditMode, boardId: board.id, itemId: item.id },
|
|
widgetStateRef,
|
|
});
|
|
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
|
|
}, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
|
|
|
|
const persistBoard = useCallback(
|
|
(updatedItems: typeof board.items) => {
|
|
saveBoard({ ...board, items: updatedItems });
|
|
},
|
|
[board, saveBoard],
|
|
);
|
|
|
|
const openEditModal = useCallback(() => {
|
|
openModal(
|
|
{
|
|
kind: item.kind,
|
|
value: {
|
|
advancedOptions: item.advancedOptions,
|
|
options: item.options,
|
|
integrationIds: item.integrationIds,
|
|
},
|
|
onSuccessfulEdit: (editResult) => {
|
|
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
|
|
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
|
|
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
|
|
if (!isEditMode) {
|
|
persistBoard(
|
|
board.items.map((boardItem) =>
|
|
boardItem.id !== item.id
|
|
? boardItem
|
|
: { ...boardItem, options: editResult.options, advancedOptions: editResult.advancedOptions, integrationIds: editResult.integrationIds },
|
|
),
|
|
);
|
|
}
|
|
},
|
|
integrationData: (integrationData ?? []).filter(
|
|
(integration) =>
|
|
"supportedIntegrations" in currentDefinition &&
|
|
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
|
),
|
|
integrationSupport: "supportedIntegrations" in currentDefinition,
|
|
settings,
|
|
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
{ title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` },
|
|
);
|
|
}, [
|
|
openModal,
|
|
item,
|
|
updateItemOptions,
|
|
updateItemAdvancedOptions,
|
|
updateItemIntegrations,
|
|
integrationData,
|
|
currentDefinition,
|
|
settings,
|
|
isEditMode,
|
|
persistBoard,
|
|
board,
|
|
]);
|
|
|
|
const handleToggle = useCallback(
|
|
(key: string) => (checked: boolean) => {
|
|
const newOptions = { ...options, [key]: checked };
|
|
updateItemOptions({ itemId: item.id, newOptions });
|
|
persistBoard(
|
|
board.items.map((boardItem) =>
|
|
boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions },
|
|
),
|
|
);
|
|
},
|
|
[options, item.id, updateItemOptions, persistBoard, board],
|
|
);
|
|
|
|
if (!session) return <>{children}</>;
|
|
|
|
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
|
|
|
|
return (
|
|
<Menu shadow="md" width={300} closeOnItemClick={false} position="right-start" offset={4}>
|
|
<Menu.ContextMenu>{children}</Menu.ContextMenu>
|
|
<Menu.Dropdown>
|
|
{toggleOptions.length > 0 && (
|
|
<>
|
|
<Menu.Label>{tMenu("options")}</Menu.Label>
|
|
{toggleOptions.map(([key]) => (
|
|
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
|
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
<Group wrap="nowrap">
|
|
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
|
|
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
|
|
</Group>
|
|
</Menu.Item>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{visibleWidgetActions.length > 0 && (
|
|
<>
|
|
{toggleOptions.length > 0 && <Menu.Divider />}
|
|
<Menu.Label>{tMenu("actions")}</Menu.Label>
|
|
{visibleWidgetActions.map((action) => {
|
|
const Icon = action.icon;
|
|
return (
|
|
<Menu.Item
|
|
key={action.key}
|
|
closeMenuOnClick
|
|
leftSection={Icon ? <Icon size={16} /> : undefined}
|
|
onClick={action.onClick}
|
|
disabled={action.disabled}
|
|
color={action.color}
|
|
>
|
|
{String(translateIfNecessary(t, action.label))}
|
|
</Menu.Item>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
{isEditMode && (
|
|
<>
|
|
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
|
<Menu.Item
|
|
closeMenuOnClick
|
|
leftSection={<IconLayoutKanban size={16} />}
|
|
onClick={() => {
|
|
if (!gridstack.current) return;
|
|
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
|
}}
|
|
>
|
|
{tItem("action.moveResize")}
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
closeMenuOnClick
|
|
leftSection={<IconCopy size={16} />}
|
|
onClick={() => duplicateItem({ itemId: item.id })}
|
|
>
|
|
{tItem("action.duplicate")}
|
|
</Menu.Item>
|
|
</>
|
|
)}
|
|
|
|
<>
|
|
{!isEditMode && (toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
|
<Menu.Item
|
|
closeMenuOnClick
|
|
leftSection={<IconSettings size={16} />}
|
|
onClick={openEditModal}
|
|
disabled={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>
|
|
</>
|
|
)}
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
);
|
|
};
|