Merge pull request #29515 from marknotfound/entity-context-menu-blueprint

Catalog: Implement EntityContextMenuItemBlueprint to extend the entity page's context menu
This commit is contained in:
Patrik Oldsberg
2025-04-08 19:52:34 +02:00
committed by GitHub
20 changed files with 778 additions and 32 deletions
+37
View File
@@ -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: <span>Example Icon</span>,
useProps: () => ({
title: 'Example Href',
href: '/example-path',
disabled: false,
component: 'a',
}),
},
});
const myCustomOnClick = EntityContextMenuItemBlueprint.make({
name: 'test-click',
params: {
icon: <span>Test Icon</span>,
useProps: () => ({
title: 'Example onClick',
onClick: () => window.alert('Hello world!'),
disabled: false,
}),
},
});
```
+32
View File
@@ -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<JSX_2.Element, 'core.reactElement', {}>;
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<void>;
disabled?: boolean;
};
// (No @packageDocumentation comment for this package)
```
@@ -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: <span>Test</span>,
useProps: () => ({
title: 'Test',
href: '/somewhere',
component: 'a',
disabled: true,
}),
},
{
icon: <span>Test</span>,
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",
}
`);
});
});
@@ -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<void>;
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 (
<MenuItem {...menuItemProps} onClick={handleClick}>
<ListItemIcon>{params.icon}</ListItemIcon>
<ListItemText primary={title} />
</MenuItem>
);
};
return <Component />;
};
yield coreExtensionData.reactElement(ExtensionBoundary.lazy(node, loader));
},
});
@@ -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';
@@ -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;
}) => (
<Context.Provider value={createVersionedValueMap({ 1: { onMenuClose } })}>
{children}
</Context.Provider>
);
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 }) => (
<Provider onMenuClose={mockOnMenuClose}>{children}</Provider>
),
});
expect(result.current.onMenuClose).toBe(mockOnMenuClose);
});
});
@@ -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;
}
+1
View File
@@ -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",
+47
View File
@@ -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<JSX_2.Element, 'core.reactElement', {}>,
{
singleton: false;
optional: false;
}
>;
};
kind: 'page';
name: 'entity';
@@ -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: {
<EntityContextMenu
UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems}
UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions}
contextMenuItems={contextMenuItems}
onInspectEntity={openInspectEntityDialog}
onUnregisterEntity={openUnregisterEntityDialog}
/>
@@ -62,6 +62,7 @@ export interface EntityLayoutProps {
UNSTABLE_extraContextMenuItems?: ComponentProps<
typeof EntityHeader
>['UNSTABLE_extraContextMenuItems'];
contextMenuItems?: ComponentProps<typeof EntityHeader>['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}
/>
)}
@@ -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: <FileCopyTwoToneIcon fontSize="small" />,
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: <BugReportIcon fontSize="small" />,
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: <CancelIcon fontSize="small" />,
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 }) => (
<UnregisterEntityDialog
open
entity={entity}
onClose={() => dialog.close()}
onConfirm={() => {
dialog.close();
navigate(
unregisterRedirectRoute
? unregisterRedirectRoute()
: catalogRoute(),
);
}}
/>
));
},
};
},
},
});
export default [
unregisterEntityContextMenuItem,
inspectEntityContextMenuItem,
copyEntityUrlContextMenuItem,
];
+138
View File
@@ -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: <span>Test Icon</span>,
...params,
},
});
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
).add(menuItem);
renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
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: <span>Test Icon</span>,
...params,
},
});
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
).add(menuItem);
renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
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);
});
});
});
});
+7 -2
View File
@@ -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,
) ?? <EntityHeader />;
) ?? <EntityHeader contextMenuItems={menuItems} />;
let groups = Object.entries(defaultEntityContentGroups).reduce<Groups>(
(rest, group) => {
@@ -134,7 +139,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
const Component = () => {
return (
<AsyncEntityProvider {...useEntityFromUrl()}>
<EntityLayout header={header}>
<EntityLayout header={header} contextMenuItems={menuItems}>
{Object.values(groups).flatMap(({ title, items }) =>
items.map(output => (
<EntityLayout.Route
+2
View File
@@ -34,6 +34,7 @@ import navItems from './navItems';
import entityCards from './entityCards';
import entityContents from './entityContents';
import searchResultItems from './searchResultItems';
import contextMenuItems from './contextMenuItems';
/** @alpha */
export default createFrontendPlugin({
@@ -55,6 +56,7 @@ export default createFrontendPlugin({
...navItems,
...entityCards,
...entityContents,
...contextMenuItems,
...searchResultItems,
],
});
@@ -1,5 +1,5 @@
/*
* Copyright 2023 The Backstage Authors
* 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.
@@ -15,6 +15,7 @@
*/
import Divider from '@material-ui/core/Divider';
import FileCopyTwoToneIcon from '@material-ui/icons/FileCopyTwoTone';
import IconButton from '@material-ui/core/IconButton';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
@@ -25,7 +26,6 @@ import Tooltip from '@material-ui/core/Tooltip';
import { Theme, makeStyles } from '@material-ui/core/styles';
import BugReportIcon from '@material-ui/icons/BugReport';
import MoreVert from '@material-ui/icons/MoreVert';
import FileCopyTwoToneIcon from '@material-ui/icons/FileCopyTwoTone';
import { SyntheticEvent, useEffect, useState } from 'react';
import { IconComponent } from '@backstage/core-plugin-api';
import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha';
@@ -35,6 +35,7 @@ import { useApi, alertApiRef } from '@backstage/core-plugin-api';
import useCopyToClipboard from 'react-use/esm/useCopyToClipboard';
import { catalogTranslationRef } from '../../alpha/translation';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { EntityContextMenuProvider } from '../../context';
/** @public */
export type EntityContextMenuClassKey = 'button';
@@ -61,6 +62,7 @@ interface ExtraContextMenuItem {
interface EntityContextMenuProps {
UNSTABLE_extraContextMenuItems?: ExtraContextMenuItem[];
UNSTABLE_contextMenuOptions?: UnregisterEntityOptions;
contextMenuItems?: React.JSX.Element[];
onUnregisterEntity: () => 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 },
}}
>
<MenuList autoFocusItem={Boolean(anchorEl)}>
{extraItems}
<UnregisterEntity
unregisterEntityOptions={UNSTABLE_contextMenuOptions}
isUnregisterAllowed={isAllowed}
onUnregisterEntity={onUnregisterEntity}
onClose={onClose}
/>
<MenuItem
onClick={() => {
onClose();
onInspectEntity();
}}
>
<ListItemIcon>
<BugReportIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t('entityContextMenu.inspectMenuTitle')} />
</MenuItem>
<MenuItem
onClick={() => {
onClose();
copyToClipboard(window.location.toString());
}}
>
<ListItemIcon>
<FileCopyTwoToneIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t('entityContextMenu.copyURLMenuTitle')} />
</MenuItem>
{contextMenuItems === undefined ? (
<>
<UnregisterEntity
unregisterEntityOptions={UNSTABLE_contextMenuOptions}
isUnregisterAllowed={isAllowed}
onUnregisterEntity={onUnregisterEntity}
onClose={onClose}
/>
<MenuItem
onClick={() => {
onClose();
onInspectEntity();
}}
>
<ListItemIcon>
<BugReportIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={t('entityContextMenu.inspectMenuTitle')}
/>
</MenuItem>
<MenuItem
onClick={() => {
onClose();
copyToClipboard(window.location.toString());
}}
>
<ListItemIcon>
<FileCopyTwoToneIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={t('entityContextMenu.copyURLMenuTitle')}
/>
</MenuItem>
</>
) : (
<EntityContextMenuProvider onMenuClose={onClose}>
{contextMenuItems}
</EntityContextMenuProvider>
)}
</MenuList>
</Popover>
</>
@@ -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 (
<EntityContextMenuContext.Provider
value={createVersionedValueMap({ 1: value })}
>
{children}
</EntityContextMenuContext.Provider>
);
};
+16
View File
@@ -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';
+1
View File
@@ -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"