From 07ad643f4da3e19e8fa1b80b2f4e12262f1ebf98 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 30 Jun 2026 17:26:52 +0200 Subject: [PATCH] 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. --- .../components/board/items/item-content.tsx | 97 +++---- .../board/items/widget-context-menu.tsx | 261 +++++++++--------- packages/translation/src/lang/en.json | 6 +- packages/widgets/src/options.ts | 1 + 4 files changed, 188 insertions(+), 177 deletions(-) diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index dfa145c63..0debd2ff8 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -34,58 +34,53 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { const board = useRequiredBoard(); const widgetStateRef = useRef | null>(null); - const cardElement = ( - - - - ); - return ( - -
- {cardElement} - {item.advancedOptions.title?.trim() && ( - - {item.advancedOptions.title} - - )} -
-
+ <> + + + + + + {item.advancedOptions.title?.trim() && ( + + {item.advancedOptions.title} + + )} + ); }; diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index 98c2ad62e..3285174d8 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -1,8 +1,8 @@ "use client"; import { useCallback, useMemo } from "react"; -import { Menu } from "@mantine/core"; -import { IconCopy, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-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"; @@ -32,6 +32,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont 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); @@ -40,6 +41,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont 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; @@ -49,23 +51,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont ); type OptionDef = { type: string; skipContextMenu?: boolean }; - const autoToggleActions = useMemo(() => { + const toggleOptions = useMemo(() => { const rawOptions = currentDefinition.createOptions(settings) as unknown as Record; - return Object.entries(rawOptions) - .filter(([, def]) => def.type === "switch" && !def.skipContextMenu) - .map(([key]) => ({ - key: `toggle-${key}`, - label: `widget.${item.kind}.option.${key}.label`, - onClick: () => { - const currentValue = options[key] as boolean | undefined; - const newOptions = { [key]: !currentValue }; - updateItemOptions({ - itemId: item.id, - newOptions: { ...options, ...newOptions }, - }); - }, - })); - }, [currentDefinition, settings, options, item, updateItemOptions]); + return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu); + }, [currentDefinition, settings]); const widgetContextActions = useMemo(() => { const def = currentDefinition as Record; @@ -74,21 +63,21 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont const actions = (def.contextActions as any)({ options, setOptions: (partial: Record) => { - updateItemOptions({ - itemId: item.id, - newOptions: { ...options, ...partial }, - }); + updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } }); }, integrationIds: item.integrationIds, - context: { - isEditMode, - boardId: board.id, - itemId: item.id, - }, + context: { isEditMode, boardId: board.id, itemId: item.id }, widgetStateRef, }); return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[]; - }, [currentDefinition, options, item, updateItemOptions, isEditMode, widgetStateRef]); + }, [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( @@ -99,19 +88,19 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont 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, - }); + 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) => @@ -123,7 +112,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any - { title: (translateFn: any) => `${translateFn("item.edit.title")} - ${translateFn(`widget.${item.kind}.name`)}` }, + { title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` }, ); }, [ openModal, @@ -134,99 +123,123 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont integrationData, currentDefinition, settings, - ]); - - const openRemoveModal = useCallback(() => { - openConfirmModal({ - title: tItem("remove.title"), - children: tItem("remove.message"), - onConfirm: () => { - removeItem({ itemId: item.id }); - }, - }); - }, [openConfirmModal, tItem, removeItem, item.id]); - - const allActions = useMemo(() => { - const actions: WidgetContextMenuAction[] = []; - - if (isEditMode) { - actions.push({ - key: "edit", - label: tItem("action.edit"), - icon: IconPencil, - onClick: openEditModal, - disabled: isPending, - }); - actions.push({ - key: "moveResize", - label: tItem("action.moveResize"), - icon: IconLayoutKanban, - onClick: () => { - if (!gridstack.current) return; - openMoveModal({ - item, - columnCount: gridstack.current.getColumn(), - gridStack: gridstack.current, - }); - }, - }); - actions.push({ - key: "duplicate", - label: tItem("action.duplicate"), - icon: IconCopy, - onClick: () => duplicateItem({ itemId: item.id }), - }); - } - - const widgetActions: WidgetContextMenuAction[] = [...autoToggleActions, ...widgetContextActions]; - actions.push(...widgetActions); - - if (isEditMode) { - actions.push({ - key: "remove", - label: tItem("action.remove"), - icon: IconTrash, - onClick: openRemoveModal, - color: "red", - }); - } - - return actions.filter((a) => !a.hidden); - }, [ isEditMode, - isPending, - autoToggleActions, - widgetContextActions, - openEditModal, - openRemoveModal, - tItem, - gridstack, - item, - openMoveModal, - duplicateItem, + 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} - {allActions.map((action) => { - const label = String(translateIfNecessary(t, action.label)); - const Icon = action.icon; - return ( + {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) && } : undefined} - onClick={action.onClick} - disabled={action.disabled} - c={action.color} + closeMenuOnClick + leftSection={} + onClick={() => { + if (!gridstack.current) return; + openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current }); + }} > - {label} + {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 190a13733..1839c1972 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": { @@ -4308,7 +4310,7 @@ "notStarted": "Not started" }, "name": { -"management": "Management", + "management": "Management", "dashboard": "Dashboard" } } diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index fea8704a5..579d7eaaf 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -92,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,