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:
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user