From 7e58858353ae866efd657270d4bb16d2c7d3e551 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 30 Jun 2026 18:10:08 +0200 Subject: [PATCH] feat(widgets): right-click context menu with auto-generated toggle actions (#6126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(helm): Update Helm chart documentation * feat(widgets): add right-click context menu with auto-generated toggle actions - Add WidgetContextMenu component using Mantine 9.3 Menu.ContextMenu - Extend WidgetDefinition with contextActions API for widget-specific actions - Auto-generate context menu toggle items for switch-type widget options - Add 'Search' context action to media-requests widgets (list + stats) - Keep existing dots-button menu for discoverability in edit mode - Add widgetStateRef to WidgetComponentProps for client-side-only state - Add skipContextMenu opt-out on switch option definitions * style: auto-fix lint and format * fix(widgets): address CodeRabbit review — badge in context menu target, edit-only pending gate - Move title badge inside WidgetContextMenu wrapper so right-clicking the title overlay also triggers the context menu - Remove full-menu hide during integration loading; only disable 'Edit' action while isPending - Add isPending to allActions useMemo dependency array * style: auto-fix lint and format * fix(widgets): gate context menu behind authenticated session - Only show right-click context menu for logged-in users - Unauthenticated users see plain children without menu wrapper - Matches existing BoardItemMenu session gating pattern * style: auto-fix lint and format * chore: revert unrelated sitemap change from auto-fix * style: auto-fix lint and format * fix(context-menu): persist widget option changes to server 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. * style: auto-fix lint and format * fix: use explicit React type imports instead of global namespace --------- Co-authored-by: github-actions[bot] --- .../components/board/items/item-content.tsx | 49 ++-- .../board/items/widget-context-menu.tsx | 250 ++++++++++++++++++ packages/translation/src/lang/en.json | 6 +- packages/widgets/src/definition.ts | 33 ++- packages/widgets/src/index.tsx | 7 +- .../widgets/src/media-requests/list/index.ts | 13 +- .../widgets/src/media-requests/stats/index.ts | 13 +- packages/widgets/src/options.ts | 2 + 8 files changed, 346 insertions(+), 27 deletions(-) create mode 100644 apps/nextjs/src/components/board/items/widget-context-menu.tsx diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 9f56a7d48..3d7ecfd3a 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,3 +1,5 @@ +import type { MutableRefObject } from "react"; +import { useRef } from "react"; import { Badge, Card } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; import { QueryErrorResetBoundary } from "@tanstack/react-query"; @@ -16,6 +18,7 @@ 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"; interface BoardItemContentProps { item: SectionItem; @@ -30,30 +33,33 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => { export const BoardItemContent = ({ item }: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); const board = useRequiredBoard(); + const widgetStateRef = useRef | null>(null); return ( <> - - - + + + + + {item.advancedOptions.title?.trim() && ( | null>; } const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx new file mode 100644 index 000000000..1dc960f58 --- /dev/null +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -0,0 +1,250 @@ +"use client"; + +import type { MutableRefObject, ReactNode } from "react"; +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: MutableRefObject | null>; + children: 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, + [item.kind, settings, item.options], + ); + + type OptionDef = { type: string; skipContextMenu?: boolean }; + const toggleOptions = useMemo(() => { + const rawOptions = currentDefinition.createOptions(settings) as unknown as Record; + return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu); + }, [currentDefinition, settings]); + + const widgetContextActions = useMemo(() => { + const def = currentDefinition as Record; + 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) => { + 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 ( + + {children} + + {toggleOptions.length > 0 && ( + <> + {tMenu("options")} + {toggleOptions.map(([key]) => ( + handleToggle(key)(!options[key])}> + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))} + + + + ))} + + )} + + {visibleWidgetActions.length > 0 && ( + <> + {toggleOptions.length > 0 && } + {tMenu("actions")} + {visibleWidgetActions.map((action) => { + const Icon = action.icon; + return ( + : undefined} + onClick={action.onClick} + disabled={action.disabled} + color={action.color} + > + {String(translateIfNecessary(t, action.label))} + + ); + })} + + )} + + {isEditMode && ( + <> + {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } + } + onClick={() => { + if (!gridstack.current) return; + openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current }); + }} + > + {tItem("action.moveResize")} + + } + onClick={() => duplicateItem({ itemId: item.id })} + > + {tItem("action.duplicate")} + + + )} + + <> + {!isEditMode && (toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } + } + onClick={openEditModal} + disabled={isPending} + > + {tMenu("settings")} + + + + {isEditMode && ( + <> + + } + onClick={() => { + openConfirmModal({ + title: tItem("remove.title"), + children: tItem("remove.message"), + onConfirm: () => removeItem({ itemId: item.id }), + }); + }} + > + {tItem("action.remove")} + + + )} + + + ); +}; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index b6eb826dc..cbbd87012 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1598,7 +1598,9 @@ }, "menu": { "label": { - "settings": "Settings" + "settings": "Settings", + "options": "Options", + "actions": "Actions" } }, "create": { @@ -4311,7 +4313,7 @@ "notStarted": "Not started" }, "name": { -"management": "Management", + "management": "Management", "dashboard": "Dashboard" } } diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 4c65d231e..a6e769d5d 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -1,3 +1,4 @@ +import type React from "react"; import type { LoaderComponent } from "next/dynamic"; import type { QueryClient } from "@tanstack/react-query"; import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import"; @@ -9,7 +10,35 @@ import type { stringOrTranslation } from "@homarr/translation"; import type { TablerIcon } from "@homarr/ui"; import type { WidgetImports } from "."; -import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options"; +import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options"; + +export interface WidgetContextMenuAction { + key: string; + label: stringOrTranslation; + icon?: TablerIcon; + onClick: () => void; + hidden?: boolean; + disabled?: boolean; + color?: string; +} + +export interface WidgetContextMenuContext { + isEditMode: boolean; + boardId: string | undefined; + itemId: string | undefined; +} + +export interface WidgetContextActionProps< + TKind extends WidgetKind, + TOptions extends WidgetOptionsRecord = WidgetOptionsRecord, +> { + kind: TKind; + options: inferOptionsFromDefinition; + setOptions: (partial: Partial>) => void; + integrationIds: string[]; + context: WidgetContextMenuContext; + widgetStateRef: React.MutableRefObject | null>; +} const createWithDynamicImport = (kind: TKind, definition: TDefinition) => @@ -56,6 +85,7 @@ export interface WidgetDefinition { } > >; + contextActions?: (props: Omit, "kind">) => WidgetContextMenuAction[]; } export interface WidgetProps { @@ -70,6 +100,7 @@ export type WidgetComponentProps = WidgetProps setOptions: ({ newOptions }: { newOptions: Partial>> }) => void; width: number; height: number; + widgetStateRef?: React.MutableRefObject | null>; }; export type WidgetOptionsRecordOf = WidgetImports[TKind]["definition"]["createOptions"]; diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 17e9b8eed..ca8a5f62f 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -61,7 +61,12 @@ import * as video from "./video"; import * as weather from "./weather"; import * as customApi from "./custom-api"; -export type { WidgetDefinition, WidgetOptionsSettings } from "./definition"; +export type { + WidgetDefinition, + WidgetContextMenuAction, + WidgetContextActionProps, + WidgetOptionsSettings, +} from "./definition"; export type { WidgetComponentProps }; export type { WidgetOptionDefinition, WidgetOptionType } from "./options"; diff --git a/packages/widgets/src/media-requests/list/index.ts b/packages/widgets/src/media-requests/list/index.ts index 8f1a88575..d4e0fcd54 100644 --- a/packages/widgets/src/media-requests/list/index.ts +++ b/packages/widgets/src/media-requests/list/index.ts @@ -1,6 +1,7 @@ -import { IconZoomQuestion } from "@tabler/icons-react"; +import { IconSearch, IconZoomQuestion } from "@tabler/icons-react"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { openMediaRequestSearch } from "@homarr/spotlight"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -14,5 +15,15 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ }), })); }, + contextActions: ({ integrationIds }) => [ + { + key: "search", + label: (t) => t("search.mode.media.action.search.label"), + icon: IconSearch, + onClick: () => { + openMediaRequestSearch({ integrationIds }); + }, + }, + ], supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-requests/stats/index.ts b/packages/widgets/src/media-requests/stats/index.ts index 25911c894..d49cb8495 100644 --- a/packages/widgets/src/media-requests/stats/index.ts +++ b/packages/widgets/src/media-requests/stats/index.ts @@ -1,6 +1,7 @@ -import { IconChartBar } from "@tabler/icons-react"; +import { IconChartBar, IconSearch } from "@tabler/icons-react"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { openMediaRequestSearch } from "@homarr/spotlight"; import { createWidgetDefinition } from "../../definition"; @@ -9,5 +10,15 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ createOptions() { return {}; }, + contextActions: ({ integrationIds }) => [ + { + key: "search", + label: (t) => t("search.mode.media.action.search.label"), + icon: IconSearch, + onClick: () => { + openMediaRequestSearch({ integrationIds }); + }, + }, + ], supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index 1d19f628a..579d7eaaf 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -13,6 +13,7 @@ import type { ReleasesRepository } from "./releases/releases-repository"; interface CommonInput { defaultValue?: TType; withDescription?: boolean; + skipContextMenu?: boolean; } interface TextInput extends CommonInput { @@ -91,6 +92,7 @@ const optionsFactory = { type: "switch" as const, defaultValue: input?.defaultValue ?? false, withDescription: input?.withDescription ?? false, + skipContextMenu: input?.skipContextMenu ?? false, }), text: (input?: TextInput) => ({ type: "text" as const,