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.
This commit is contained in:
Thomas Camlong committed 2026-06-30 17:26:52 +02:00
1 parent f5b4625c8e
commit 07ad643f4d
4 files changed
+188 -177

No files matched your search

@@ -34,58 +34,53 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const widgetStateRef = useRef<Record<string, unknown> | null>(null); const widgetStateRef = useRef<Record<string, unknown> | null>(null);
const cardElement = (
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
</Card>
);
return ( return (
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}> <>
<div style={{ position: "relative" }}> <WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
{cardElement} <Card
{item.advancedOptions.title?.trim() && ( ref={ref}
<Badge className={combineClasses(
pos="absolute" classes.itemCard,
// It's 4 because of the mantine-react-table that has z-index 3 `${item.kind}-wrapper`,
style={{ zIndex: 4 }} "grid-stack-item-content",
top={2} item.advancedOptions.customCssClasses.join(" "),
left={16} )}
size="xs" radius={board.itemRadius}
radius={board.itemRadius} styles={{
styles={{ root: {
root: { "--opacity": board.opacity / 100,
"--border-color": containerType: "size",
item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined, overflow: getOverflowFromKind(item.kind),
"--opacity": board.opacity / 100, "--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
}, },
}} }}
className={itemContentClasses.badge} p={0}
c="var(--mantine-color-text)" >
> <InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
{item.advancedOptions.title} </Card>
</Badge> </WidgetContextMenu>
)} {item.advancedOptions.title?.trim() && (
</div> <Badge
</WidgetContextMenu> pos="absolute"
// It's 4 because of the mantine-react-table that has z-index 3
style={{ zIndex: 4 }}
top={2}
left={16}
size="xs"
radius={board.itemRadius}
styles={{
root: {
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
"--opacity": board.opacity / 100,
},
}}
className={itemContentClasses.badge}
c="var(--mantine-color-text)"
>
{item.advancedOptions.title}
</Badge>
)}
</>
); );
}; };
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { Menu } from "@mantine/core"; import { Group, Menu, Switch } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react"; import { IconCopy, IconLayoutKanban, IconSettings, IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
@@ -32,6 +32,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const tItem = useScopedI18n("item"); const tItem = useScopedI18n("item");
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n(); const t = useI18n();
const settings = useSettings(); const settings = useSettings();
const { openModal } = useModalAction(WidgetEditModal); const { openModal } = useModalAction(WidgetEditModal);
@@ -40,6 +41,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions(); useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); 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 currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs; const { gridstack } = useSectionContext().refs;
@@ -49,23 +51,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
); );
type OptionDef = { type: string; skipContextMenu?: boolean }; type OptionDef = { type: string; skipContextMenu?: boolean };
const autoToggleActions = useMemo(() => { const toggleOptions = useMemo(() => {
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>; const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions) return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
.filter(([, def]) => def.type === "switch" && !def.skipContextMenu) }, [currentDefinition, settings]);
.map<WidgetContextMenuAction>(([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]);
const widgetContextActions = useMemo(() => { const widgetContextActions = useMemo(() => {
const def = currentDefinition as Record<string, unknown>; const def = currentDefinition as Record<string, unknown>;
@@ -74,21 +63,21 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const actions = (def.contextActions as any)({ const actions = (def.contextActions as any)({
options, options,
setOptions: (partial: Record<string, unknown>) => { setOptions: (partial: Record<string, unknown>) => {
updateItemOptions({ updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
itemId: item.id,
newOptions: { ...options, ...partial },
});
}, },
integrationIds: item.integrationIds, integrationIds: item.integrationIds,
context: { context: { isEditMode, boardId: board.id, itemId: item.id },
isEditMode,
boardId: board.id,
itemId: item.id,
},
widgetStateRef, widgetStateRef,
}); });
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[]; 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(() => { const openEditModal = useCallback(() => {
openModal( openModal(
@@ -99,19 +88,19 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
options: item.options, options: item.options,
integrationIds: item.integrationIds, integrationIds: item.integrationIds,
}, },
onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => { onSuccessfulEdit: (editResult) => {
updateItemOptions({ updateItemOptions({ itemId: item.id, newOptions: editResult.options });
itemId: item.id, updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
newOptions: options, updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
}); if (!isEditMode) {
updateItemAdvancedOptions({ persistBoard(
itemId: item.id, board.items.map((boardItem) =>
newAdvancedOptions: advancedOptions, boardItem.id !== item.id
}); ? boardItem
updateItemIntegrations({ : { ...boardItem, options: editResult.options, advancedOptions: editResult.advancedOptions, integrationIds: editResult.integrationIds },
itemId: item.id, ),
newIntegrations: integrationIds, );
}); }
}, },
integrationData: (integrationData ?? []).filter( integrationData: (integrationData ?? []).filter(
(integration) => (integration) =>
@@ -123,7 +112,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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, openModal,
@@ -134,99 +123,123 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
integrationData, integrationData,
currentDefinition, currentDefinition,
settings, 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, isEditMode,
isPending, persistBoard,
autoToggleActions, board,
widgetContextActions,
openEditModal,
openRemoveModal,
tItem,
gridstack,
item,
openMoveModal,
duplicateItem,
]); ]);
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}</>; if (!session) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
return ( return (
<Menu shadow="md" width={200}> <Menu shadow="md" width={300} closeOnItemClick={false} position="right-start" offset={4}>
<Menu.ContextMenu>{children}</Menu.ContextMenu> <Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown> <Menu.Dropdown>
{allActions.map((action) => { {toggleOptions.length > 0 && (
const label = String(translateIfNecessary(t, action.label)); <>
const Icon = action.icon; <Menu.Label>{tMenu("options")}</Menu.Label>
return ( {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 <Menu.Item
key={action.key} closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined} leftSection={<IconLayoutKanban size={16} />}
onClick={action.onClick} onClick={() => {
disabled={action.disabled} if (!gridstack.current) return;
c={action.color} openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
> >
{label} {tItem("action.moveResize")}
</Menu.Item> </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.Dropdown>
</Menu> </Menu>
); );
+4 -2
View File
@@ -1598,7 +1598,9 @@
}, },
"menu": { "menu": {
"label": { "label": {
"settings": "Settings" "settings": "Settings",
"options": "Options",
"actions": "Actions"
} }
}, },
"create": { "create": {
@@ -4308,7 +4310,7 @@
"notStarted": "Not started" "notStarted": "Not started"
}, },
"name": { "name": {
"management": "Management", "management": "Management",
"dashboard": "Dashboard" "dashboard": "Dashboard"
} }
} }
+1
View File
@@ -92,6 +92,7 @@ const optionsFactory = {
type: "switch" as const, type: "switch" as const,
defaultValue: input?.defaultValue ?? false, defaultValue: input?.defaultValue ?? false,
withDescription: input?.withDescription ?? false, withDescription: input?.withDescription ?? false,
skipContextMenu: input?.skipContextMenu ?? false,
}), }),
text: (input?: TextInput) => ({ text: (input?: TextInput) => ({
type: "text" as const, type: "text" as const,