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:
@@ -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,
|
||||
}),
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user