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
This commit is contained in:
1 parent
597b5fbc8d
commit
83b93e17da
7 files changed
+364
-25
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { useRef } from "react";
|
||||
import { Badge, Card } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
@@ -16,6 +17,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 +32,37 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
const board = useRequiredBoard();
|
||||
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 (
|
||||
<>
|
||||
<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} />
|
||||
</Card>
|
||||
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
|
||||
{cardElement}
|
||||
</WidgetContextMenu>
|
||||
{item.advancedOptions.title?.trim() && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
@@ -83,6 +92,7 @@ interface InnerContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>;
|
||||
}
|
||||
|
||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Menu } from "@mantine/core";
|
||||
import {
|
||||
IconCopy,
|
||||
IconLayoutKanban,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/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: React.MutableRefObject<Record<string, unknown> | null>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const WidgetContextMenu = ({
|
||||
item,
|
||||
widgetStateRef,
|
||||
children,
|
||||
}: WidgetContextMenuProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const tItem = useScopedI18n("item");
|
||||
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 currentDefinition = useMemo(
|
||||
() => widgetImports[item.kind].definition,
|
||||
[item.kind],
|
||||
);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
reduceWidgetOptionsWithDefaultValues(
|
||||
item.kind,
|
||||
settings,
|
||||
item.options,
|
||||
) as Record<string, unknown>,
|
||||
[item.kind, settings, item.options],
|
||||
);
|
||||
|
||||
type OptionDef = { type: string; skipContextMenu?: boolean };
|
||||
const autoToggleActions = useMemo(() => {
|
||||
const rawOptions = currentDefinition.createOptions(
|
||||
settings,
|
||||
) as unknown as Record<string, OptionDef>;
|
||||
return Object.entries(rawOptions)
|
||||
.filter(([, def]) => def.type === "switch" && !def.skipContextMenu)
|
||||
.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 def = currentDefinition as Record<string, unknown>;
|
||||
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<string, unknown>) => {
|
||||
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,
|
||||
widgetStateRef,
|
||||
]);
|
||||
|
||||
const openEditModal = useCallback(() => {
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
value: {
|
||||
advancedOptions: item.advancedOptions,
|
||||
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,
|
||||
});
|
||||
},
|
||||
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: (translateFn: any) => `${translateFn("item.edit.title")} - ${translateFn(`widget.${item.kind}.name`)}` },
|
||||
);
|
||||
}, [
|
||||
openModal,
|
||||
item,
|
||||
updateItemOptions,
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
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,
|
||||
});
|
||||
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,
|
||||
autoToggleActions,
|
||||
widgetContextActions,
|
||||
openEditModal,
|
||||
openRemoveModal,
|
||||
tItem,
|
||||
gridstack,
|
||||
item,
|
||||
openMoveModal,
|
||||
duplicateItem,
|
||||
]);
|
||||
|
||||
if (isPending) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.ContextMenu>{children}</Menu.ContextMenu>
|
||||
<Menu.Dropdown>
|
||||
{allActions.map((action) => {
|
||||
const label = String(translateIfNecessary(t, action.label));
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={action.key}
|
||||
leftSection={Icon ? <Icon size={16} /> : undefined}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
c={action.color}
|
||||
>
|
||||
{label}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -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<TOptions>;
|
||||
setOptions: (partial: Partial<inferOptionsFromDefinition<TOptions>>) => void;
|
||||
integrationIds: string[];
|
||||
context: WidgetContextMenuContext;
|
||||
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>;
|
||||
}
|
||||
|
||||
const createWithDynamicImport =
|
||||
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
|
||||
@@ -56,6 +85,9 @@ export interface WidgetDefinition {
|
||||
}
|
||||
>
|
||||
>;
|
||||
contextActions?: (
|
||||
props: Omit<WidgetContextActionProps<WidgetKind>, "kind">,
|
||||
) => WidgetContextMenuAction[];
|
||||
}
|
||||
|
||||
export interface WidgetProps<TKind extends WidgetKind> {
|
||||
@@ -70,6 +102,7 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
|
||||
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
|
||||
};
|
||||
|
||||
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["createOptions"];
|
||||
|
||||
@@ -61,7 +61,7 @@ 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";
|
||||
|
||||
|
||||
@@ -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"));
|
||||
@@ -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"));
|
||||
@@ -13,6 +13,7 @@ import type { ReleasesRepository } from "./releases/releases-repository";
|
||||
interface CommonInput<TType> {
|
||||
defaultValue?: TType;
|
||||
withDescription?: boolean;
|
||||
skipContextMenu?: boolean;
|
||||
}
|
||||
|
||||
interface TextInput extends CommonInput<string> {
|
||||
|
||||
Reference in new issue
Block a user