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 ( + + {params.icon} + + + ); + }; + + 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} - - { - onClose(); - onInspectEntity(); - }} - > - - - - - - { - onClose(); - copyToClipboard(window.location.toString()); - }} - > - - - - - + {contextMenuItems === undefined ? ( + <> + + { + onClose(); + onInspectEntity(); + }} + > + + + + + + { + onClose(); + copyToClipboard(window.location.toString()); + }} + > + + + + + + + ) : ( + + {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"