diff --git a/.changeset/proud-dots-fry.md b/.changeset/proud-dots-fry.md
new file mode 100644
index 0000000000..2895f3d720
--- /dev/null
+++ b/.changeset/proud-dots-fry.md
@@ -0,0 +1,37 @@
+---
+'@backstage/plugin-catalog-react': minor
+'@backstage/plugin-catalog': minor
+---
+
+Adds `EntityContextMenuItemBlueprint` to enable extending the entity page's context menu with user defined items.
+
+For example:
+
+```ts
+import { EntityContextMenuItemBlueprint } from '@backstage/plugin-catalog-react/alpha';
+
+const myCustomHref = EntityContextMenuItemBlueprint.make({
+ name: 'test-href',
+ params: {
+ icon: Example Icon,
+ useProps: () => ({
+ title: 'Example Href',
+ href: '/example-path',
+ disabled: false,
+ component: 'a',
+ }),
+ },
+});
+
+const myCustomOnClick = EntityContextMenuItemBlueprint.make({
+ name: 'test-click',
+ params: {
+ icon: Test Icon,
+ useProps: () => ({
+ title: 'Example onClick',
+ onClick: () => window.alert('Hello world!'),
+ disabled: false,
+ }),
+ },
+});
+```
diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md
index e03367dd8b..b9a064043c 100644
--- a/plugins/catalog-react/report-alpha.api.md
+++ b/plugins/catalog-react/report-alpha.api.md
@@ -11,6 +11,7 @@ import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { JsonValue } from '@backstage/types';
import { JSX as JSX_2 } from 'react';
+import { ReactNode } from 'react';
import { ResourcePermission } from '@backstage/plugin-permission-common';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
@@ -325,6 +326,24 @@ export interface EntityContentLayoutProps {
}>;
}
+// @alpha (undocumented)
+export const EntityContextMenuItemBlueprint: ExtensionBlueprint<{
+ kind: 'entity-context-menu-item';
+ name: undefined;
+ params: EntityContextMenuItemParams;
+ output: ConfigurableExtensionDataRef;
+ inputs: {};
+ config: {};
+ configInput: {};
+ dataRefs: never;
+}>;
+
+// @alpha (undocumented)
+export type EntityContextMenuItemParams = {
+ useProps: UseProps;
+ icon: JSX_2.Element;
+};
+
// @alpha (undocumented)
export const EntityHeaderBlueprint: ExtensionBlueprint<{
kind: 'entity-header';
@@ -405,5 +424,18 @@ export function useEntityPermission(
error?: Error;
};
+// @alpha (undocumented)
+export type UseProps = () =>
+ | {
+ title: ReactNode;
+ href: string;
+ disabled?: boolean;
+ }
+ | {
+ title: ReactNode;
+ onClick: () => void | Promise;
+ disabled?: boolean;
+ };
+
// (No @packageDocumentation comment for this package)
```
diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx
new file mode 100644
index 0000000000..3bdfc00db7
--- /dev/null
+++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { EntityContextMenuItemBlueprint } from './EntityContextMenuItemBlueprint';
+
+describe('EntityContextMenuItemBlueprint', () => {
+ const data = [
+ {
+ icon: Test,
+ useProps: () => ({
+ title: 'Test',
+ href: '/somewhere',
+ component: 'a',
+ disabled: true,
+ }),
+ },
+ {
+ icon: Test,
+ useProps: () => ({
+ title: 'Test',
+ onClick: async () => {},
+ }),
+ },
+ ];
+
+ it.each(data)('should return an extension with sane defaults', params => {
+ const extension = EntityContextMenuItemBlueprint.make({
+ name: 'test',
+ params,
+ });
+
+ expect(extension).toMatchInlineSnapshot(`
+ {
+ "$$type": "@backstage/ExtensionDefinition",
+ "T": undefined,
+ "attachTo": {
+ "id": "page:catalog/entity",
+ "input": "contextMenuItems",
+ },
+ "configSchema": undefined,
+ "disabled": false,
+ "factory": [Function],
+ "inputs": {},
+ "kind": "entity-context-menu-item",
+ "name": "test",
+ "output": [
+ [Function],
+ ],
+ "override": [Function],
+ "toString": [Function],
+ "version": "v2",
+ }
+ `);
+ });
+});
diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx
new file mode 100644
index 0000000000..f43dd6a6d9
--- /dev/null
+++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ReactNode, JSX } from 'react';
+import {
+ coreExtensionData,
+ createExtensionBlueprint,
+ ExtensionBoundary,
+} from '@backstage/frontend-plugin-api';
+import MenuItem from '@material-ui/core/MenuItem';
+import ListItemIcon from '@material-ui/core/ListItemIcon';
+import ListItemText from '@material-ui/core/ListItemText';
+import { useEntityContextMenu } from '../../hooks/useEntityContextMenu';
+
+/** @alpha */
+export type UseProps = () =>
+ | {
+ title: ReactNode;
+ href: string;
+ disabled?: boolean;
+ }
+ | {
+ title: ReactNode;
+ onClick: () => void | Promise;
+ disabled?: boolean;
+ };
+
+/** @alpha */
+export type EntityContextMenuItemParams = {
+ useProps: UseProps;
+ icon: JSX.Element;
+};
+
+/** @alpha */
+export const EntityContextMenuItemBlueprint = createExtensionBlueprint({
+ kind: 'entity-context-menu-item',
+ attachTo: { id: 'page:catalog/entity', input: 'contextMenuItems' },
+ output: [coreExtensionData.reactElement],
+ *factory(params: EntityContextMenuItemParams, { node }) {
+ const loader = async () => {
+ const Component = () => {
+ const { onMenuClose } = useEntityContextMenu();
+ const { title, ...menuItemProps } = params.useProps();
+ let handleClick = undefined;
+
+ if ('onClick' in menuItemProps) {
+ handleClick = () => {
+ const result = menuItemProps.onClick();
+ if (result && 'then' in result) {
+ result.then(onMenuClose, onMenuClose);
+ } else {
+ onMenuClose();
+ }
+ };
+ }
+
+ return (
+
+ );
+ };
+
+ return ;
+ };
+
+ yield coreExtensionData.reactElement(ExtensionBoundary.lazy(node, loader));
+ },
+});
diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts
index dc3fa7de1b..b21522331d 100644
--- a/plugins/catalog-react/src/alpha/blueprints/index.ts
+++ b/plugins/catalog-react/src/alpha/blueprints/index.ts
@@ -23,3 +23,8 @@ export {
export { EntityHeaderBlueprint } from './EntityHeaderBlueprint';
export { defaultEntityContentGroups } from './extensionData';
export type { EntityCardType } from './extensionData';
+export {
+ EntityContextMenuItemBlueprint,
+ type EntityContextMenuItemParams,
+ type UseProps,
+} from './EntityContextMenuItemBlueprint';
diff --git a/plugins/catalog-react/src/hooks/useEntityContextMenu.test.tsx b/plugins/catalog-react/src/hooks/useEntityContextMenu.test.tsx
new file mode 100644
index 0000000000..60757a6050
--- /dev/null
+++ b/plugins/catalog-react/src/hooks/useEntityContextMenu.test.tsx
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { ReactNode } from 'react';
+import { useEntityContextMenu } from './useEntityContextMenu';
+import { renderHook } from '@testing-library/react';
+import {
+ createVersionedContext,
+ createVersionedValueMap,
+} from '@backstage/version-bridge';
+
+const Context = createVersionedContext<{
+ 1: { onMenuClose: () => void };
+}>('entity-context-menu-context');
+
+const Provider = ({
+ children,
+ onMenuClose,
+}: {
+ children: ReactNode;
+ onMenuClose: () => void;
+}) => (
+
+ {children}
+
+);
+
+describe('useEntityContextMenu', () => {
+ it('should throw error when used outside of provider', () => {
+ expect(() => {
+ renderHook(() => useEntityContextMenu());
+ }).toThrow(
+ 'useEntityContextMenu must be used within an EntityContextMenuProvider',
+ );
+ });
+
+ it('should return the context value', () => {
+ const mockOnMenuClose = jest.fn();
+ const { result } = renderHook(() => useEntityContextMenu(), {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ });
+
+ expect(result.current.onMenuClose).toBe(mockOnMenuClose);
+ });
+});
diff --git a/plugins/catalog-react/src/hooks/useEntityContextMenu.ts b/plugins/catalog-react/src/hooks/useEntityContextMenu.ts
new file mode 100644
index 0000000000..6eb7aba2ea
--- /dev/null
+++ b/plugins/catalog-react/src/hooks/useEntityContextMenu.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { useVersionedContext } from '@backstage/version-bridge';
+
+/** @internal */
+export type EntityContextMenuContextValue = {
+ onMenuClose: () => void;
+};
+
+/** @internal */
+export function useEntityContextMenu() {
+ const versionedHolder = useVersionedContext<{
+ 1: EntityContextMenuContextValue;
+ }>('entity-context-menu-context');
+
+ if (!versionedHolder) {
+ throw new Error(
+ 'useEntityContextMenu must be used within an EntityContextMenuProvider',
+ );
+ }
+
+ const value = versionedHolder.atVersion(1);
+ if (!value) {
+ throw new Error('EntityContextMenu v1 is not available');
+ }
+
+ return value;
+}
diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json
index 51675d225e..9d69805e7d 100644
--- a/plugins/catalog/package.json
+++ b/plugins/catalog/package.json
@@ -73,6 +73,7 @@
"@backstage/plugin-search-common": "workspace:^",
"@backstage/plugin-search-react": "workspace:^",
"@backstage/types": "workspace:^",
+ "@backstage/version-bridge": "workspace:^",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md
index c63f0f4fce..54d4187536 100644
--- a/plugins/catalog/report-alpha.api.md
+++ b/plugins/catalog/report-alpha.api.md
@@ -11,6 +11,7 @@ import { defaultEntityContentGroups } from '@backstage/plugin-catalog-react/alph
import { Entity } from '@backstage/catalog-model';
import { EntityCardType } from '@backstage/plugin-catalog-react/alpha';
import { EntityContentLayoutProps } from '@backstage/plugin-catalog-react/alpha';
+import { EntityContextMenuItemParams } from '@backstage/plugin-catalog-react/alpha';
import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
@@ -859,6 +860,45 @@ const _default: FrontendPlugin<
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
};
}>;
+ 'entity-context-menu-item:catalog/copy-entity-url': ExtensionDefinition<{
+ kind: 'entity-context-menu-item';
+ name: 'copy-entity-url';
+ config: {};
+ configInput: {};
+ output: ConfigurableExtensionDataRef<
+ JSX_2.Element,
+ 'core.reactElement',
+ {}
+ >;
+ inputs: {};
+ params: EntityContextMenuItemParams;
+ }>;
+ 'entity-context-menu-item:catalog/inspect-entity': ExtensionDefinition<{
+ kind: 'entity-context-menu-item';
+ name: 'inspect-entity';
+ config: {};
+ configInput: {};
+ output: ConfigurableExtensionDataRef<
+ JSX_2.Element,
+ 'core.reactElement',
+ {}
+ >;
+ inputs: {};
+ params: EntityContextMenuItemParams;
+ }>;
+ 'entity-context-menu-item:catalog/unregister-entity': ExtensionDefinition<{
+ kind: 'entity-context-menu-item';
+ name: 'unregister-entity';
+ config: {};
+ configInput: {};
+ output: ConfigurableExtensionDataRef<
+ JSX_2.Element,
+ 'core.reactElement',
+ {}
+ >;
+ inputs: {};
+ params: EntityContextMenuItemParams;
+ }>;
'nav-item:catalog': ExtensionDefinition<{
kind: 'nav-item';
name: undefined;
@@ -1004,6 +1044,13 @@ const _default: FrontendPlugin<
optional: false;
}
>;
+ contextMenuItems: ExtensionInput<
+ ConfigurableExtensionDataRef,
+ {
+ singleton: false;
+ optional: false;
+ }
+ >;
};
kind: 'page';
name: 'entity';
diff --git a/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx b/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx
index ca81ffe7cc..75e0baa113 100644
--- a/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx
+++ b/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx
@@ -178,6 +178,7 @@ export function EntityHeader(props: {
UNSTABLE_contextMenuOptions?: {
disableUnregister: boolean | 'visible' | 'hidden' | 'disable';
};
+ contextMenuItems?: React.JSX.Element[];
/**
* An array of relation types used to determine the parent entities in the hierarchy.
* These relations are prioritized in the order provided, allowing for flexible
@@ -195,6 +196,7 @@ export function EntityHeader(props: {
const {
UNSTABLE_extraContextMenuItems,
UNSTABLE_contextMenuOptions,
+ contextMenuItems,
parentEntityRelations,
title,
subtitle,
@@ -281,6 +283,7 @@ export function EntityHeader(props: {
diff --git a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx
index abf1e69bfb..750c8567ee 100644
--- a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx
+++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx
@@ -62,6 +62,7 @@ export interface EntityLayoutProps {
UNSTABLE_extraContextMenuItems?: ComponentProps<
typeof EntityHeader
>['UNSTABLE_extraContextMenuItems'];
+ contextMenuItems?: ComponentProps['contextMenuItems'];
children?: ReactNode;
header?: JSX.Element;
NotFoundComponent?: ReactNode;
@@ -99,6 +100,7 @@ export const EntityLayout = (props: EntityLayoutProps) => {
const {
UNSTABLE_extraContextMenuItems,
UNSTABLE_contextMenuOptions,
+ contextMenuItems,
children,
header,
NotFoundComponent,
@@ -145,6 +147,7 @@ export const EntityLayout = (props: EntityLayoutProps) => {
parentEntityRelations={parentEntityRelations}
UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions}
UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems}
+ contextMenuItems={contextMenuItems}
/>
)}
diff --git a/plugins/catalog/src/alpha/contextMenuItems.tsx b/plugins/catalog/src/alpha/contextMenuItems.tsx
new file mode 100644
index 0000000000..d503bc33a1
--- /dev/null
+++ b/plugins/catalog/src/alpha/contextMenuItems.tsx
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ EntityContextMenuItemBlueprint,
+ useEntityPermission,
+} from '@backstage/plugin-catalog-react/alpha';
+import FileCopyTwoToneIcon from '@material-ui/icons/FileCopyTwoTone';
+import BugReportIcon from '@material-ui/icons/BugReport';
+import CancelIcon from '@material-ui/icons/Cancel';
+import useCopyToClipboard from 'react-use/esm/useCopyToClipboard';
+import { alertApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
+import {
+ dialogApiRef,
+ useTranslationRef,
+ type DialogApiDialog,
+} from '@backstage/frontend-plugin-api';
+import { catalogTranslationRef } from './translation';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import {
+ UnregisterEntityDialog,
+ useEntity,
+} from '@backstage/plugin-catalog-react';
+import { rootRouteRef, unregisterRedirectRouteRef } from '../routes';
+import { catalogEntityDeletePermission } from '@backstage/plugin-catalog-common/alpha';
+import { useEffect } from 'react';
+
+export const copyEntityUrlContextMenuItem = EntityContextMenuItemBlueprint.make(
+ {
+ name: 'copy-entity-url',
+ params: {
+ icon: ,
+ useProps: () => {
+ const [copyState, copyToClipboard] = useCopyToClipboard();
+ const alertApi = useApi(alertApiRef);
+ const { t } = useTranslationRef(catalogTranslationRef);
+
+ useEffect(() => {
+ if (!copyState.error && copyState.value) {
+ alertApi.post({
+ message: t('entityContextMenu.copiedMessage'),
+ severity: 'info',
+ display: 'transient',
+ });
+ }
+ }, [copyState, alertApi, t]);
+
+ return {
+ title: t('entityContextMenu.copyURLMenuTitle'),
+ onClick: async () => {
+ copyToClipboard(window.location.toString());
+ },
+ };
+ },
+ },
+ },
+);
+
+export const inspectEntityContextMenuItem = EntityContextMenuItemBlueprint.make(
+ {
+ name: 'inspect-entity',
+ params: {
+ icon: ,
+ useProps: () => {
+ const [_, setSearchParams] = useSearchParams();
+ const { t } = useTranslationRef(catalogTranslationRef);
+
+ return {
+ title: t('entityContextMenu.inspectMenuTitle'),
+ onClick: async () => {
+ setSearchParams('inspect');
+ },
+ };
+ },
+ },
+ },
+);
+
+export const unregisterEntityContextMenuItem =
+ EntityContextMenuItemBlueprint.make({
+ name: 'unregister-entity',
+ params: {
+ icon: ,
+ useProps: () => {
+ const { entity } = useEntity();
+ const dialogApi = useApi(dialogApiRef);
+ const navigate = useNavigate();
+ const catalogRoute = useRouteRef(rootRouteRef);
+ const { t } = useTranslationRef(catalogTranslationRef);
+ const unregisterRedirectRoute = useRouteRef(unregisterRedirectRouteRef);
+ const unregisterPermission = useEntityPermission(
+ catalogEntityDeletePermission,
+ );
+
+ return {
+ title: t('entityContextMenu.unregisterMenuTitle'),
+ disabled: !unregisterPermission.allowed,
+ onClick: async () => {
+ dialogApi.showModal(({ dialog }: { dialog: DialogApiDialog }) => (
+ dialog.close()}
+ onConfirm={() => {
+ dialog.close();
+ navigate(
+ unregisterRedirectRoute
+ ? unregisterRedirectRoute()
+ : catalogRoute(),
+ );
+ }}
+ />
+ ));
+ },
+ };
+ },
+ },
+ });
+
+export default [
+ unregisterEntityContextMenuItem,
+ inspectEntityContextMenuItem,
+ copyEntityUrlContextMenuItem,
+];
diff --git a/plugins/catalog/src/alpha/pages.test.tsx b/plugins/catalog/src/alpha/pages.test.tsx
index ab81d93342..f5aeb7d196 100644
--- a/plugins/catalog/src/alpha/pages.test.tsx
+++ b/plugins/catalog/src/alpha/pages.test.tsx
@@ -24,6 +24,7 @@ import {
import { catalogEntityPage } from './pages';
import {
EntityContentBlueprint,
+ EntityContextMenuItemBlueprint,
EntityHeaderBlueprint,
} from '@backstage/plugin-catalog-react/alpha';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
@@ -596,4 +597,141 @@ describe('Entity page', () => {
);
});
});
+
+ describe('Entity Page Context Menu', () => {
+ const onClickMock = jest.fn();
+ beforeEach(() => {
+ onClickMock.mockReset();
+ });
+
+ it.each([
+ {
+ useProps: () => ({
+ title: 'Test Title',
+ href: '/somewhere',
+ disabled: true,
+ component: 'a',
+ }),
+ },
+ {
+ useProps: () => ({
+ title: 'Test Title',
+ href: '/somewhere',
+ disabled: false,
+ component: 'a',
+ }),
+ },
+ ])('should render an href based context menu item', async params => {
+ const menuItem = EntityContextMenuItemBlueprint.make({
+ name: 'test-href',
+ params: {
+ icon: Test Icon,
+ ...params,
+ },
+ });
+ const tester = createExtensionTester(
+ Object.assign({ namespace: 'catalog' }, catalogEntityPage),
+ ).add(menuItem);
+
+ renderInTestApp(
+
+ {tester.reactElement()}
+ ,
+ {
+ config: {
+ app: {
+ title: 'Custom app',
+ },
+ backend: { baseUrl: 'http://localhost:7000' },
+ },
+ mountedRoutes: {
+ '/catalog': convertLegacyRouteRef(rootRouteRef),
+ '/catalog/:namespace/:kind/:name':
+ convertLegacyRouteRef(entityRouteRef),
+ },
+ },
+ );
+ const { disabled } = params.useProps();
+
+ await waitFor(async () => {
+ await userEvent.click(screen.getByTestId('menu-button'));
+ expect(screen.getByText('Test Title')).toBeInTheDocument();
+ expect(screen.getByText('Test Icon')).toBeInTheDocument();
+ const anchor = screen.getByText('Test Title').closest('a');
+ expect(anchor).toHaveAttribute('href', '/somewhere');
+ expect(anchor).toHaveAttribute('aria-disabled', disabled.toString());
+ });
+ });
+
+ it.each([
+ {
+ useProps: () => ({
+ title: 'Test Title',
+ onClick: onClickMock,
+ disabled: true,
+ }),
+ },
+ {
+ useProps: () => ({
+ title: 'Test Title',
+ onClick: onClickMock,
+ disabled: false,
+ }),
+ },
+ ])('should render an onClick based context menu item', async params => {
+ const menuItem = EntityContextMenuItemBlueprint.make({
+ name: 'test-click',
+ params: {
+ icon: Test Icon,
+ ...params,
+ },
+ });
+ const tester = createExtensionTester(
+ Object.assign({ namespace: 'catalog' }, catalogEntityPage),
+ ).add(menuItem);
+
+ renderInTestApp(
+
+ {tester.reactElement()}
+ ,
+ {
+ config: {
+ app: {
+ title: 'Custom app',
+ },
+ backend: { baseUrl: 'http://localhost:7000' },
+ },
+ mountedRoutes: {
+ '/catalog': convertLegacyRouteRef(rootRouteRef),
+ '/catalog/:namespace/:kind/:name':
+ convertLegacyRouteRef(entityRouteRef),
+ },
+ },
+ );
+
+ const { disabled } = params.useProps();
+ await waitFor(async () => {
+ await userEvent.click(screen.getByTestId('menu-button'));
+ expect(screen.getByText('Test Title')).toBeInTheDocument();
+ expect(screen.getByText('Test Icon')).toBeInTheDocument();
+ const listItem = screen.getByText('Test Title').closest('li');
+ expect(listItem).toHaveAttribute('aria-disabled', disabled.toString());
+ if (!disabled) {
+ await userEvent.click(screen.getByText('Test Title'));
+ }
+
+ expect(onClickMock).toHaveBeenCalledTimes(disabled ? 0 : 1);
+ });
+ });
+ });
});
diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx
index be2fb341c3..b39fe510e6 100644
--- a/plugins/catalog/src/alpha/pages.tsx
+++ b/plugins/catalog/src/alpha/pages.tsx
@@ -72,6 +72,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
EntityContentBlueprint.dataRefs.filterExpression.optional(),
EntityContentBlueprint.dataRefs.group.optional(),
]),
+ contextMenuItems: createExtensionInput([coreExtensionData.reactElement]),
},
config: {
schema: {
@@ -88,6 +89,10 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
loader: async () => {
const { EntityLayout } = await import('./components/EntityLayout');
+ const menuItems = inputs.contextMenuItems.map(item =>
+ item.get(coreExtensionData.reactElement),
+ );
+
type Groups = Record<
string,
{ title: string; items: Array<(typeof inputs.contents)[0]> }
@@ -95,7 +100,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
const header = inputs.header?.get(
EntityHeaderBlueprint.dataRefs.element,
- ) ?? ;
+ ) ?? ;
let groups = Object.entries(defaultEntityContentGroups).reduce(
(rest, group) => {
@@ -134,7 +139,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
const Component = () => {
return (
-
+
{Object.values(groups).flatMap(({ title, items }) =>
items.map(output => (
void;
onInspectEntity: () => void;
}
@@ -69,6 +71,7 @@ export function EntityContextMenu(props: EntityContextMenuProps) {
const {
UNSTABLE_extraContextMenuItems,
UNSTABLE_contextMenuOptions,
+ contextMenuItems,
onUnregisterEntity,
onInspectEntity,
} = props;
@@ -142,37 +145,52 @@ export function EntityContextMenu(props: EntityContextMenuProps) {
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
aria-labelledby="long-menu"
+ PaperProps={{
+ style: { minWidth: 200 },
+ }}
>
{extraItems}
-
-
-
+ {contextMenuItems === undefined ? (
+ <>
+
+
+
+ >
+ ) : (
+
+ {contextMenuItems}
+
+ )}
>
diff --git a/plugins/catalog/src/context/EntityContextMenuContext.tsx b/plugins/catalog/src/context/EntityContextMenuContext.tsx
new file mode 100644
index 0000000000..ec2f823b07
--- /dev/null
+++ b/plugins/catalog/src/context/EntityContextMenuContext.tsx
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+ createVersionedContext,
+ createVersionedValueMap,
+} from '@backstage/version-bridge';
+import { ReactNode } from 'react';
+
+/** @internal */
+export type EntityContextMenuContextValue = {
+ onMenuClose: () => void;
+};
+
+const EntityContextMenuContext = createVersionedContext<{
+ 1: EntityContextMenuContextValue;
+}>('entity-context-menu-context');
+
+/** @internal */
+export interface EntityContextMenuProviderProps {
+ children: ReactNode;
+ onMenuClose: () => void;
+}
+
+/** @internal */
+export const EntityContextMenuProvider = (
+ props: EntityContextMenuProviderProps,
+) => {
+ const { children, onMenuClose } = props;
+ const value = { onMenuClose };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/plugins/catalog/src/context/index.ts b/plugins/catalog/src/context/index.ts
new file mode 100644
index 0000000000..919829d314
--- /dev/null
+++ b/plugins/catalog/src/context/index.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export { EntityContextMenuProvider } from './EntityContextMenuContext';
diff --git a/yarn.lock b/yarn.lock
index 215e6ce4bf..fe9f4075ef 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6390,6 +6390,7 @@ __metadata:
"@backstage/plugin-search-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
+ "@backstage/version-bridge": "workspace:^"
"@material-ui/core": "npm:^4.12.2"
"@material-ui/icons": "npm:^4.9.1"
"@material-ui/lab": "npm:4.0.0-alpha.61"