Files
homarr/apps/nextjs/src/components/board/items/item-content.tsx
T
Thomasandgithub-actions[bot] 7e58858353 feat(widgets): right-click context menu with auto-generated toggle actions (#6126)
* 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] <github-actions[bot]@users.noreply.github.com>
2026-06-30 18:10:08 +02:00

156 lines
5.3 KiB
TypeScript

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";
import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
import { ErrorBoundary } from "react-error-boundary";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors";
import type { SectionItem } from "~/app/[locale]/boards/_types";
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;
}
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
if (kind === "iframe") return "hidden";
if (kind === "systemResources") return "visible";
return undefined;
};
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
return (
<>
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
<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>
</WidgetContextMenu>
{item.advancedOptions.title?.trim() && (
<Badge
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>
)}
</>
);
};
interface InnerContentProps {
item: SectionItem;
width: number;
height: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
}
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const Comp = loadWidgetDynamic(item.kind);
const { definition } = widgetImports[item.kind];
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const newItem = { ...item, options };
const { updateItemOptions } = useItemActions();
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
updateItemOptions({ itemId: item.id, newOptions });
const widgetSupportsIntegrations =
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
</>
)}
>
<Throw
error={new NoIntegrationSelectedError()}
when={
widgetSupportsIntegrations &&
item.integrationIds.length === 0 &&
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
}
/>
<BoardItemMenu offset={4} item={newItem} />
<Comp
options={options as never}
integrationIds={item.integrationIds}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
setOptions={(partialNewOptions) =>
updateOptions({
newOptions: {
...options,
...partialNewOptions.newOptions,
},
})
}
{...dimensions}
/>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
};
const Throw = ({ when, error }: { when: boolean; error: Error }) => {
if (when) throw error;
return null;
};