From 3e40a77aeb82add1541c3d7a63b12da6b12e87ab Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Tue, 11 Mar 2025 10:56:29 -0400 Subject: [PATCH 01/28] add EntityContextMenuItemBlueprint Signed-off-by: Mark Dunphy --- .../EntityContextMenuItemBlueprint.tsx | 74 +++++++++++++++++++ .../src/alpha/blueprints/index.ts | 5 ++ .../components/EntityHeader/EntityHeader.tsx | 3 + .../components/EntityLayout/EntityLayout.tsx | 3 + plugins/catalog/src/alpha/pages.tsx | 11 ++- .../EntityContextMenu/EntityContextMenu.tsx | 3 + .../components/EntityLayout/EntityLayout.tsx | 3 + 7 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx 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..f2c7657927 --- /dev/null +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -0,0 +1,74 @@ +/* + * 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 React from 'react'; +import { + ExtensionBoundary, + coreExtensionData, + createExtensionBlueprint, +} 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'; + +/** @alpha */ +export type FactoryLoaderParams = { + loader: () => Promise; +}; + +/** @alpha */ +export type FactoryHrefParams = + | { + title: string; + icon: JSX.Element; + useHref: () => string; + } + | { + title: string; + icon: JSX.Element; + href: string; + }; + +/** @alpha */ +export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ + kind: 'entity-context-menu-item', + attachTo: { id: 'page:catalog/entity', input: 'extraContextMenuItems' }, + output: [coreExtensionData.reactElement], + *factory(params: FactoryLoaderParams | FactoryHrefParams, { node }) { + const loaderFactory = () => { + if ('loader' in params) { + return params.loader; + } + + const useHref = 'useHref' in params ? params.useHref : () => params.href; + + return async () => { + const href = useHref(); + + return ( + + {params.icon} + + + ); + }; + }; + + yield coreExtensionData.reactElement( + ExtensionBoundary.lazy(node, loaderFactory()), + ); + }, +}); diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts index dc3fa7de1b..857cdef5ea 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 FactoryHrefParams, + type FactoryLoaderParams, +} from './EntityContextMenuItemBlueprint'; diff --git a/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx b/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx index ca81ffe7cc..4a1af40168 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'; }; + extraMenuItems?: 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, + extraMenuItems, 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..5c92981fa5 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']; + extraMenuItems?: ComponentProps['extraMenuItems']; children?: ReactNode; header?: JSX.Element; NotFoundComponent?: ReactNode; @@ -99,6 +100,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { const { UNSTABLE_extraContextMenuItems, UNSTABLE_contextMenuOptions, + extraMenuItems, children, header, NotFoundComponent, @@ -145,6 +147,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { parentEntityRelations={parentEntityRelations} UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions} UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems} + extraMenuItems={extraMenuItems} /> )} diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index be2fb341c3..c8dc7b2913 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -72,6 +72,9 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ EntityContentBlueprint.dataRefs.filterExpression.optional(), EntityContentBlueprint.dataRefs.group.optional(), ]), + extraContextMenuItems: createExtensionInput([ + coreExtensionData.reactElement, + ]), }, config: { schema: { @@ -88,6 +91,10 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ loader: async () => { const { EntityLayout } = await import('./components/EntityLayout'); + const extraMenuItems = inputs.extraContextMenuItems.map(item => + item.get(coreExtensionData.reactElement), + ); + type Groups = Record< string, { title: string; items: Array<(typeof inputs.contents)[0]> } @@ -95,7 +102,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ const header = inputs.header?.get( EntityHeaderBlueprint.dataRefs.element, - ) ?? ; + ) ?? ; let groups = Object.entries(defaultEntityContentGroups).reduce( (rest, group) => { @@ -134,7 +141,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ const Component = () => { return ( - + {Object.values(groups).flatMap(({ title, items }) => items.map(output => ( void; onInspectEntity: () => void; } @@ -69,6 +70,7 @@ export function EntityContextMenu(props: EntityContextMenuProps) { const { UNSTABLE_extraContextMenuItems, UNSTABLE_contextMenuOptions, + extraMenuItems, onUnregisterEntity, onInspectEntity, } = props; @@ -145,6 +147,7 @@ export function EntityContextMenu(props: EntityContextMenuProps) { > {extraItems} + {extraMenuItems} { const { UNSTABLE_extraContextMenuItems, UNSTABLE_contextMenuOptions, + extraMenuItems, children, NotFoundComponent, parentEntityRelations, @@ -360,6 +362,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { setConfirmationDialogOpen(true)} onInspectEntity={() => setSearchParams('inspect')} /> From d9895fbd6211c74ed8efe259df4c93ccbf91511e Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Tue, 11 Mar 2025 11:14:22 -0400 Subject: [PATCH 02/28] add support for dialog api Signed-off-by: Mark Dunphy --- .../EntityContextMenuItemBlueprint.test.tsx | 76 +++++++++++++++++++ .../EntityContextMenuItemBlueprint.tsx | 31 +++++++- 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx 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..466256f25b --- /dev/null +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx @@ -0,0 +1,76 @@ +/* + * 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 React from 'react'; +import { EntityContextMenuItemBlueprint } from './EntityContextMenuItemBlueprint'; +import { createRouteRef, useRouteRef } from '@backstage/frontend-plugin-api'; + +describe('EntityContextMenuItemBlueprint', () => { + const routeRef = createRouteRef(); + const data = [ + { + loader: async () =>
  • Test!
  • , + }, + { + title: 'Test', + href: '/somewhere', + icon: Test, + }, + { + title: 'Test', + useHref() { + const r = useRouteRef(routeRef) ?? (() => '/somewhere'); + return r(); + }, + icon: Test, + }, + { + title: 'TestDialog', + dialogLoader: async () => () =>
    test dialog
    , + icon: Test, + }, + ]; + + 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": "extraContextMenuItems", + }, + "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 index f2c7657927..bfb3eb17c7 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -16,9 +16,11 @@ import React from 'react'; import { + DialogApiDialog, ExtensionBoundary, coreExtensionData, createExtensionBlueprint, + dialogApiRef, } from '@backstage/frontend-plugin-api'; import MenuItem from '@material-ui/core/MenuItem'; import ListItemIcon from '@material-ui/core/ListItemIcon'; @@ -42,17 +44,44 @@ export type FactoryHrefParams = href: string; }; +/** @alpha */ +export type FactoryDialogParams = { + dialogLoader: () => Promise< + ({ dialog }: { dialog: DialogApiDialog }) => JSX.Element + >; + title: string; + icon: JSX.Element; +}; + +export type EntityContextMenuItemParams = + | FactoryLoaderParams + | FactoryHrefParams + | FactoryDialogParams; + /** @alpha */ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ kind: 'entity-context-menu-item', attachTo: { id: 'page:catalog/entity', input: 'extraContextMenuItems' }, output: [coreExtensionData.reactElement], - *factory(params: FactoryLoaderParams | FactoryHrefParams, { node }) { + *factory(params: EntityContextMenuItemParams, { node, apis }) { const loaderFactory = () => { if ('loader' in params) { return params.loader; } + if ('dialogLoader' in params) { + const dialogApi = apis.get(dialogApiRef); + return async () => { + const Dialog = await params.dialogLoader(); + return ( + dialogApi?.show(Dialog)}> + {params.icon} + + + ); + }; + } + const useHref = 'useHref' in params ? params.useHref : () => params.href; return async () => { From 62e0fd26adb38f645e15db5bf5783e34ebd1ff60 Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Tue, 11 Mar 2025 11:20:46 -0400 Subject: [PATCH 03/28] api reports Signed-off-by: Mark Dunphy --- plugins/catalog-react/report-alpha.api.md | 50 +++++++++++++++++++ .../EntityContextMenuItemBlueprint.tsx | 1 + .../src/alpha/blueprints/index.ts | 2 + plugins/catalog/report-alpha.api.md | 7 +++ plugins/catalog/report.api.md | 2 + 5 files changed, 62 insertions(+) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index e03367dd8b..a3d495bc2a 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -6,6 +6,7 @@ import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ComponentType } from 'react'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { DialogApiDialog } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; @@ -325,6 +326,28 @@ export interface EntityContentLayoutProps { }>; } +// @alpha (undocumented) +export const EntityContextMenuItemBlueprint: ExtensionBlueprint<{ + kind: 'entity-context-menu-item'; + name: undefined; + params: EntityContextMenuItemParams; + output: ConfigurableExtensionDataRef< + React_2.JSX.Element, + 'core.reactElement', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: never; +}>; + +// @alpha (undocumented) +export type EntityContextMenuItemParams = + | FactoryLoaderParams + | FactoryHrefParams + | FactoryDialogParams; + // @alpha (undocumented) export const EntityHeaderBlueprint: ExtensionBlueprint<{ kind: 'entity-header'; @@ -393,6 +416,33 @@ export type EntityPredicateValue = $contains: EntityPredicateExpression; }; +// @alpha (undocumented) +export type FactoryDialogParams = { + dialogLoader: () => Promise< + ({ dialog }: { dialog: DialogApiDialog }) => JSX.Element + >; + title: string; + icon: JSX.Element; +}; + +// @alpha (undocumented) +export type FactoryHrefParams = + | { + title: string; + icon: JSX.Element; + useHref: () => string; + } + | { + title: string; + icon: JSX.Element; + href: string; + }; + +// @alpha (undocumented) +export type FactoryLoaderParams = { + loader: () => Promise; +}; + // @alpha export function isOwnerOf(owner: Entity, entity: Entity): boolean; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index bfb3eb17c7..5a4daeaf24 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -53,6 +53,7 @@ export type FactoryDialogParams = { icon: JSX.Element; }; +/** @alpha */ export type EntityContextMenuItemParams = | FactoryLoaderParams | FactoryHrefParams diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts index 857cdef5ea..0d4e65a39c 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -27,4 +27,6 @@ export { EntityContextMenuItemBlueprint, type FactoryHrefParams, type FactoryLoaderParams, + type FactoryDialogParams, + type EntityContextMenuItemParams, } from './EntityContextMenuItemBlueprint'; diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index c63f0f4fce..54f534a537 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -1004,6 +1004,13 @@ const _default: FrontendPlugin< optional: false; } >; + extraContextMenuItems: ExtensionInput< + ConfigurableExtensionDataRef, + { + singleton: false; + optional: false; + } + >; }; kind: 'page'; name: 'entity'; diff --git a/plugins/catalog/report.api.md b/plugins/catalog/report.api.md index 10e120d208..3ddf164802 100644 --- a/plugins/catalog/report.api.md +++ b/plugins/catalog/report.api.md @@ -404,6 +404,8 @@ export interface EntityLayoutProps { children?: ReactNode; // (undocumented) NotFoundComponent?: ReactNode; + // (undocumented) + extraMenuItems?: JSX.Element[]; parentEntityRelations?: string[]; // Warning: (ae-forgotten-export) The symbol "EntityContextMenuOptions" needs to be exported by the entry point index.d.ts // From 09afd67a5484a0b3b5241d6a2beeb437a89c5e0a Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Tue, 11 Mar 2025 11:24:52 -0400 Subject: [PATCH 04/28] add changeset Signed-off-by: Mark Dunphy --- .changeset/proud-dots-fry.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/proud-dots-fry.md diff --git a/.changeset/proud-dots-fry.md b/.changeset/proud-dots-fry.md new file mode 100644 index 0000000000..2b75355332 --- /dev/null +++ b/.changeset/proud-dots-fry.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog-react': minor +'@backstage/plugin-catalog': minor +--- + +Adds `EntityContextMenuItemBlueprint` to enable extending the entity page's context menu From e0a4ae8e5cb20097d93a30aa3027a82b977489cd Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Wed, 19 Mar 2025 14:48:41 -0400 Subject: [PATCH 05/28] rename input from extraContextMenuItems to contextMenuItems Signed-off-by: Mark Dunphy --- .../blueprints/EntityContextMenuItemBlueprint.test.tsx | 2 +- .../blueprints/EntityContextMenuItemBlueprint.tsx | 2 +- plugins/catalog/src/alpha/pages.tsx | 10 ++++------ 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx index 466256f25b..4f386d207e 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx @@ -56,7 +56,7 @@ describe('EntityContextMenuItemBlueprint', () => { "T": undefined, "attachTo": { "id": "page:catalog/entity", - "input": "extraContextMenuItems", + "input": "contextMenuItems", }, "configSchema": undefined, "disabled": false, diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index 5a4daeaf24..1bb9ede1a9 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -62,7 +62,7 @@ export type EntityContextMenuItemParams = /** @alpha */ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ kind: 'entity-context-menu-item', - attachTo: { id: 'page:catalog/entity', input: 'extraContextMenuItems' }, + attachTo: { id: 'page:catalog/entity', input: 'contextMenuItems' }, output: [coreExtensionData.reactElement], *factory(params: EntityContextMenuItemParams, { node, apis }) { const loaderFactory = () => { diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index c8dc7b2913..b7832f3a16 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -72,9 +72,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ EntityContentBlueprint.dataRefs.filterExpression.optional(), EntityContentBlueprint.dataRefs.group.optional(), ]), - extraContextMenuItems: createExtensionInput([ - coreExtensionData.reactElement, - ]), + contextMenuItems: createExtensionInput([coreExtensionData.reactElement]), }, config: { schema: { @@ -91,7 +89,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ loader: async () => { const { EntityLayout } = await import('./components/EntityLayout'); - const extraMenuItems = inputs.extraContextMenuItems.map(item => + const menuItems = inputs.contextMenuItems.map(item => item.get(coreExtensionData.reactElement), ); @@ -102,7 +100,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ const header = inputs.header?.get( EntityHeaderBlueprint.dataRefs.element, - ) ?? ; + ) ?? ; let groups = Object.entries(defaultEntityContentGroups).reduce( (rest, group) => { @@ -141,7 +139,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ const Component = () => { return ( - + {Object.values(groups).flatMap(({ title, items }) => items.map(output => ( Date: Thu, 20 Mar 2025 16:12:40 -0400 Subject: [PATCH 06/28] move to useOnClick and migrate entity URL copy to blueprint Signed-off-by: Mark Dunphy --- .../EntityContextMenuItemBlueprint.tsx | 80 ++++++++++--------- .../src/alpha/blueprints/index.ts | 4 +- .../components/EntityHeader/EntityHeader.tsx | 3 +- .../catalog/src/alpha/contextMenuItems.tsx | 57 +++++++++++++ plugins/catalog/src/alpha/pages.tsx | 5 +- plugins/catalog/src/alpha/plugin.tsx | 2 + .../catalog/src/alpha/searchResultItems.tsx | 2 +- .../EntityContextMenu/EntityContextMenu.tsx | 19 ++--- 8 files changed, 117 insertions(+), 55 deletions(-) create mode 100644 plugins/catalog/src/alpha/contextMenuItems.tsx diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index 1bb9ede1a9..17dfede815 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -16,68 +16,77 @@ import React from 'react'; import { - DialogApiDialog, - ExtensionBoundary, - coreExtensionData, createExtensionBlueprint, - dialogApiRef, + ApiHolder, + createExtensionDataRef, } 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'; -/** @alpha */ -export type FactoryLoaderParams = { - loader: () => Promise; -}; - /** @alpha */ export type FactoryHrefParams = | { - title: string; - icon: JSX.Element; + useTitle: () => string; + icon: React.JSX.Element; useHref: () => string; } | { - title: string; - icon: JSX.Element; + useTitle: () => string; + icon: React.JSX.Element; href: string; }; /** @alpha */ export type FactoryDialogParams = { - dialogLoader: () => Promise< - ({ dialog }: { dialog: DialogApiDialog }) => JSX.Element - >; - title: string; - icon: JSX.Element; + useOnClick: ({ + apis, + }: { + apis: ApiHolder; + }) => React.MouseEventHandler; + useTitle: () => string; + icon: React.JSX.Element; }; /** @alpha */ export type EntityContextMenuItemParams = - | FactoryLoaderParams | FactoryHrefParams | FactoryDialogParams; +export type ContextMenuItemProps = { + onClose: () => void; +}; + +export type ContextMenuItemComponent = ( + props: ContextMenuItemProps, +) => React.JSX.Element; + +export const contextMenuItemComponentDataRef = + createExtensionDataRef().with({ + id: 'catalog.contextMenuItemComponent', + }); + /** @alpha */ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ kind: 'entity-context-menu-item', attachTo: { id: 'page:catalog/entity', input: 'contextMenuItems' }, - output: [coreExtensionData.reactElement], - *factory(params: EntityContextMenuItemParams, { node, apis }) { - const loaderFactory = () => { - if ('loader' in params) { - return params.loader; - } + output: [contextMenuItemComponentDataRef], + *factory(params: EntityContextMenuItemParams, { apis }) { + const loaderFactory = (): ContextMenuItemComponent => { + if ('useOnClick' in params) { + return ({ onClose }) => { + const onClick = params.useOnClick({ apis }); + const title = params.useTitle(); - if ('dialogLoader' in params) { - const dialogApi = apis.get(dialogApiRef); - return async () => { - const Dialog = await params.dialogLoader(); return ( - dialogApi?.show(Dialog)}> + { + onClick(e); + onClose(); + }} + > {params.icon} - + ); }; @@ -85,20 +94,19 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ const useHref = 'useHref' in params ? params.useHref : () => params.href; - return async () => { + return () => { const href = useHref(); + const title = params.useTitle(); return ( {params.icon} - + ); }; }; - yield coreExtensionData.reactElement( - ExtensionBoundary.lazy(node, loaderFactory()), - ); + yield contextMenuItemComponentDataRef(loaderFactory()); }, }); diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts index 0d4e65a39c..004652f716 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -24,9 +24,11 @@ export { EntityHeaderBlueprint } from './EntityHeaderBlueprint'; export { defaultEntityContentGroups } from './extensionData'; export type { EntityCardType } from './extensionData'; export { + contextMenuItemComponentDataRef, EntityContextMenuItemBlueprint, + type ContextMenuItemProps, + type ContextMenuItemComponent, type FactoryHrefParams, - type FactoryLoaderParams, type FactoryDialogParams, type EntityContextMenuItemParams, } from './EntityContextMenuItemBlueprint'; diff --git a/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx b/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx index 4a1af40168..7fa6471d3e 100644 --- a/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx +++ b/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx @@ -55,6 +55,7 @@ import { import { EntityLabels } from '../EntityLabels'; import { EntityContextMenu } from '../../../components/EntityContextMenu'; import { rootRouteRef, unregisterRedirectRouteRef } from '../../../routes'; +import { ContextMenuItemComponent } from '@backstage/plugin-catalog-react/alpha'; function headerProps( paramKind: string | undefined, @@ -178,7 +179,7 @@ export function EntityHeader(props: { UNSTABLE_contextMenuOptions?: { disableUnregister: boolean | 'visible' | 'hidden' | 'disable'; }; - extraMenuItems?: JSX.Element[]; + extraMenuItems?: ContextMenuItemComponent[]; /** * 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 diff --git a/plugins/catalog/src/alpha/contextMenuItems.tsx b/plugins/catalog/src/alpha/contextMenuItems.tsx new file mode 100644 index 0000000000..33479b6f65 --- /dev/null +++ b/plugins/catalog/src/alpha/contextMenuItems.tsx @@ -0,0 +1,57 @@ +/* + * 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 React from 'react'; +import { EntityContextMenuItemBlueprint } from '@backstage/plugin-catalog-react/alpha'; +import FileCopyTwoToneIcon from '@material-ui/icons/FileCopyTwoTone'; +import useCopyToClipboard from 'react-use/esm/useCopyToClipboard'; +import { alertApiRef, useApi } from '@backstage/core-plugin-api'; +import { useTranslationRef } from '@backstage/frontend-plugin-api'; +import { catalogTranslationRef } from './translation'; + +export const copyEntityUrlContextMenuItem = EntityContextMenuItemBlueprint.make( + { + name: 'copy-entity-url', + params: { + icon: , + useTitle: () => { + const { t } = useTranslationRef(catalogTranslationRef); + return t('entityContextMenu.copyURLMenuTitle'); + }, + useOnClick: () => { + const [copyState, copyToClipboard] = useCopyToClipboard(); + const alertApi = useApi(alertApiRef); + const { t } = useTranslationRef(catalogTranslationRef); + + React.useEffect(() => { + if (!copyState.error && copyState.value) { + alertApi.post({ + message: t('entityContextMenu.copiedMessage'), + severity: 'info', + display: 'transient', + }); + } + }, [copyState, alertApi, t]); + + return async () => { + copyToClipboard(window.location.toString()); + }; + }, + }, + }, +); + +export default [copyEntityUrlContextMenuItem]; diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index b7832f3a16..74ea49f685 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -31,6 +31,7 @@ import { EntityHeaderBlueprint, EntityContentBlueprint, defaultEntityContentGroups, + contextMenuItemComponentDataRef, } from '@backstage/plugin-catalog-react/alpha'; import { rootRouteRef } from '../routes'; import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl'; @@ -72,7 +73,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ EntityContentBlueprint.dataRefs.filterExpression.optional(), EntityContentBlueprint.dataRefs.group.optional(), ]), - contextMenuItems: createExtensionInput([coreExtensionData.reactElement]), + contextMenuItems: createExtensionInput([contextMenuItemComponentDataRef]), }, config: { schema: { @@ -90,7 +91,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ const { EntityLayout } = await import('./components/EntityLayout'); const menuItems = inputs.contextMenuItems.map(item => - item.get(coreExtensionData.reactElement), + item.get(contextMenuItemComponentDataRef), ); type Groups = Record< diff --git a/plugins/catalog/src/alpha/plugin.tsx b/plugins/catalog/src/alpha/plugin.tsx index 8a18500043..04034e2156 100644 --- a/plugins/catalog/src/alpha/plugin.tsx +++ b/plugins/catalog/src/alpha/plugin.tsx @@ -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, ], }); diff --git a/plugins/catalog/src/alpha/searchResultItems.tsx b/plugins/catalog/src/alpha/searchResultItems.tsx index 8e140de637..db8d0bfc09 100644 --- a/plugins/catalog/src/alpha/searchResultItems.tsx +++ b/plugins/catalog/src/alpha/searchResultItems.tsx @@ -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. diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index ecfae2c26c..c0ba1a500c 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -25,7 +25,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 +34,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 type { ContextMenuItemComponent } from '@backstage/plugin-catalog-react/alpha'; /** @public */ export type EntityContextMenuClassKey = 'button'; @@ -61,7 +61,7 @@ interface ExtraContextMenuItem { interface EntityContextMenuProps { UNSTABLE_extraContextMenuItems?: ExtraContextMenuItem[]; UNSTABLE_contextMenuOptions?: UnregisterEntityOptions; - extraMenuItems?: JSX.Element[]; + extraMenuItems?: ContextMenuItemComponent[]; onUnregisterEntity: () => void; onInspectEntity: () => void; } @@ -147,7 +147,6 @@ export function EntityContextMenu(props: EntityContextMenuProps) { > {extraItems} - {extraMenuItems} - { - onClose(); - copyToClipboard(window.location.toString()); - }} - > - - - - - + {extraMenuItems?.map(ExtraMenuItem => ( + + ))}
    From bf5e50d63801246fdb39d74fc54af8b984d154fd Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Fri, 21 Mar 2025 14:40:24 -0400 Subject: [PATCH 07/28] remove api holder arg from useOnClick Signed-off-by: Mark Dunphy --- .../EntityContextMenuItemBlueprint.tsx | 10 ++++------ .../EntityContextMenu/EntityContextMenu.tsx | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index 17dfede815..aeb0d7e3b4 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -19,6 +19,8 @@ import { createExtensionBlueprint, ApiHolder, createExtensionDataRef, + dialogApiRef, + useApiHolder, } from '@backstage/frontend-plugin-api'; import MenuItem from '@material-ui/core/MenuItem'; import ListItemIcon from '@material-ui/core/ListItemIcon'; @@ -39,11 +41,7 @@ export type FactoryHrefParams = /** @alpha */ export type FactoryDialogParams = { - useOnClick: ({ - apis, - }: { - apis: ApiHolder; - }) => React.MouseEventHandler; + useOnClick: () => React.MouseEventHandler; useTitle: () => string; icon: React.JSX.Element; }; @@ -81,8 +79,8 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ return ( { - onClick(e); onClose(); + onClick(e); }} > {params.icon} diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index c0ba1a500c..75191a303b 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -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'; @@ -147,6 +148,9 @@ export function EntityContextMenu(props: EntityContextMenuProps) { > {extraItems} + {extraMenuItems?.map(ExtraMenuItem => ( + + ))} - {extraMenuItems?.map(ExtraMenuItem => ( - - ))} + { + onClose(); + copyToClipboard(window.location.toString()); + }} + > + + + + + From b12ae854b9be68fddfa5f515e1f4a94cbfb585d8 Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Fri, 21 Mar 2025 14:58:02 -0400 Subject: [PATCH 08/28] make context menu items first class Signed-off-by: Mark Dunphy --- .../EntityContextMenuItemBlueprint.tsx | 14 ++-- .../components/EntityHeader/EntityHeader.tsx | 6 +- .../components/EntityLayout/EntityLayout.tsx | 6 +- plugins/catalog/src/alpha/pages.tsx | 4 +- .../EntityContextMenu/EntityContextMenu.tsx | 75 +++++++++++-------- .../components/EntityLayout/EntityLayout.tsx | 2 - 6 files changed, 57 insertions(+), 50 deletions(-) diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index aeb0d7e3b4..b5c9896696 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -17,10 +17,8 @@ import React from 'react'; import { createExtensionBlueprint, - ApiHolder, createExtensionDataRef, - dialogApiRef, - useApiHolder, + ExtensionBoundary, } from '@backstage/frontend-plugin-api'; import MenuItem from '@material-ui/core/MenuItem'; import ListItemIcon from '@material-ui/core/ListItemIcon'; @@ -69,11 +67,11 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ kind: 'entity-context-menu-item', attachTo: { id: 'page:catalog/entity', input: 'contextMenuItems' }, output: [contextMenuItemComponentDataRef], - *factory(params: EntityContextMenuItemParams, { apis }) { - const loaderFactory = (): ContextMenuItemComponent => { + *factory(params: EntityContextMenuItemParams, { node }) { + const loader = async (): Promise => { if ('useOnClick' in params) { return ({ onClose }) => { - const onClick = params.useOnClick({ apis }); + const onClick = params.useOnClick(); const title = params.useTitle(); return ( @@ -105,6 +103,8 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ }; }; - yield contextMenuItemComponentDataRef(loaderFactory()); + yield contextMenuItemComponentDataRef( + ExtensionBoundary.lazyComponent(node, loader), + ); }, }); diff --git a/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx b/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx index 7fa6471d3e..d53e8d63f3 100644 --- a/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx +++ b/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx @@ -179,7 +179,7 @@ export function EntityHeader(props: { UNSTABLE_contextMenuOptions?: { disableUnregister: boolean | 'visible' | 'hidden' | 'disable'; }; - extraMenuItems?: ContextMenuItemComponent[]; + contextMenuItems?: ContextMenuItemComponent[]; /** * 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 @@ -197,7 +197,7 @@ export function EntityHeader(props: { const { UNSTABLE_extraContextMenuItems, UNSTABLE_contextMenuOptions, - extraMenuItems, + contextMenuItems, parentEntityRelations, title, subtitle, @@ -284,7 +284,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 5c92981fa5..750c8567ee 100644 --- a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx @@ -62,7 +62,7 @@ export interface EntityLayoutProps { UNSTABLE_extraContextMenuItems?: ComponentProps< typeof EntityHeader >['UNSTABLE_extraContextMenuItems']; - extraMenuItems?: ComponentProps['extraMenuItems']; + contextMenuItems?: ComponentProps['contextMenuItems']; children?: ReactNode; header?: JSX.Element; NotFoundComponent?: ReactNode; @@ -100,7 +100,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { const { UNSTABLE_extraContextMenuItems, UNSTABLE_contextMenuOptions, - extraMenuItems, + contextMenuItems, children, header, NotFoundComponent, @@ -147,7 +147,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { parentEntityRelations={parentEntityRelations} UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions} UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems} - extraMenuItems={extraMenuItems} + contextMenuItems={contextMenuItems} /> )} diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index 74ea49f685..64287dbd77 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -101,7 +101,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ const header = inputs.header?.get( EntityHeaderBlueprint.dataRefs.element, - ) ?? ; + ) ?? ; let groups = Object.entries(defaultEntityContentGroups).reduce( (rest, group) => { @@ -140,7 +140,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ const Component = () => { return ( - + {Object.values(groups).flatMap(({ title, items }) => items.map(output => ( void; onInspectEntity: () => void; } @@ -71,7 +71,7 @@ export function EntityContextMenu(props: EntityContextMenuProps) { const { UNSTABLE_extraContextMenuItems, UNSTABLE_contextMenuOptions, - extraMenuItems, + contextMenuItems, onUnregisterEntity, onInspectEntity, } = props; @@ -148,37 +148,46 @@ export function EntityContextMenu(props: EntityContextMenuProps) { > {extraItems} - {extraMenuItems?.map(ExtraMenuItem => ( - - ))} - - { - onClose(); - onInspectEntity(); - }} - > - - - - - - { - onClose(); - copyToClipboard(window.location.toString()); - }} - > - - - - - + {contextMenuItems === undefined ? ( + <> + + { + onClose(); + onInspectEntity(); + }} + > + + + + + + { + onClose(); + copyToClipboard(window.location.toString()); + }} + > + + + + + + + ) : ( + contextMenuItems.map(ExtraMenuItem => ( + + )) + )} diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index ce0ed55363..f088a2d320 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -246,7 +246,6 @@ export const EntityLayout = (props: EntityLayoutProps) => { const { UNSTABLE_extraContextMenuItems, UNSTABLE_contextMenuOptions, - extraMenuItems, children, NotFoundComponent, parentEntityRelations, @@ -362,7 +361,6 @@ export const EntityLayout = (props: EntityLayoutProps) => { setConfirmationDialogOpen(true)} onInspectEntity={() => setSearchParams('inspect')} /> From ac209696d351349b99306df1f6de84f72afa9841 Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Fri, 21 Mar 2025 15:16:57 -0400 Subject: [PATCH 09/28] add context menu extension for inspect entity Signed-off-by: Mark Dunphy --- .../catalog/src/alpha/contextMenuItems.tsx | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/plugins/catalog/src/alpha/contextMenuItems.tsx b/plugins/catalog/src/alpha/contextMenuItems.tsx index 33479b6f65..f026b9e7a8 100644 --- a/plugins/catalog/src/alpha/contextMenuItems.tsx +++ b/plugins/catalog/src/alpha/contextMenuItems.tsx @@ -17,10 +17,12 @@ import React from 'react'; import { EntityContextMenuItemBlueprint } from '@backstage/plugin-catalog-react/alpha'; import FileCopyTwoToneIcon from '@material-ui/icons/FileCopyTwoTone'; +import BugReportIcon from '@material-ui/icons/BugReport'; import useCopyToClipboard from 'react-use/esm/useCopyToClipboard'; import { alertApiRef, useApi } from '@backstage/core-plugin-api'; import { useTranslationRef } from '@backstage/frontend-plugin-api'; import { catalogTranslationRef } from './translation'; +import { useSearchParams } from 'react-router-dom'; export const copyEntityUrlContextMenuItem = EntityContextMenuItemBlueprint.make( { @@ -54,4 +56,24 @@ export const copyEntityUrlContextMenuItem = EntityContextMenuItemBlueprint.make( }, ); -export default [copyEntityUrlContextMenuItem]; +export const inspectEntityContextMenuItem = EntityContextMenuItemBlueprint.make( + { + name: 'inspect-entity', + params: { + icon: , + useTitle: () => { + const { t } = useTranslationRef(catalogTranslationRef); + return t('entityContextMenu.inspectMenuTitle'); + }, + useOnClick: () => { + const [_, setSearchParams] = useSearchParams(); + + return () => { + setSearchParams('inspect'); + }; + }, + }, + }, +); + +export default [inspectEntityContextMenuItem, copyEntityUrlContextMenuItem]; From c8c0065394c34a8f39e78f59c67f344404b5f45e Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Fri, 21 Mar 2025 15:22:06 -0400 Subject: [PATCH 10/28] scaffold unregister entity menu item Signed-off-by: Mark Dunphy --- .../catalog/src/alpha/contextMenuItems.tsx | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/plugins/catalog/src/alpha/contextMenuItems.tsx b/plugins/catalog/src/alpha/contextMenuItems.tsx index f026b9e7a8..f17e58c286 100644 --- a/plugins/catalog/src/alpha/contextMenuItems.tsx +++ b/plugins/catalog/src/alpha/contextMenuItems.tsx @@ -18,6 +18,7 @@ import React from 'react'; import { EntityContextMenuItemBlueprint } 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 } from '@backstage/core-plugin-api'; import { useTranslationRef } from '@backstage/frontend-plugin-api'; @@ -76,4 +77,26 @@ export const inspectEntityContextMenuItem = EntityContextMenuItemBlueprint.make( }, ); -export default [inspectEntityContextMenuItem, copyEntityUrlContextMenuItem]; +export const unregisterEntityContextMenuItem = + EntityContextMenuItemBlueprint.make({ + name: 'unregister-entity', + params: { + icon: , + useTitle: () => { + const { t } = useTranslationRef(catalogTranslationRef); + return t('entityContextMenu.unregisterMenuTitle'); + }, + useOnClick: () => { + return () => { + // eslint-disable-next-line no-alert + window.alert('unregister clicked'); + }; + }, + }, + }); + +export default [ + unregisterEntityContextMenuItem, + inspectEntityContextMenuItem, + copyEntityUrlContextMenuItem, +]; From cce3770453dfa961966b53b0bc5f918d2585a7de Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Fri, 21 Mar 2025 16:34:57 -0400 Subject: [PATCH 11/28] add useIsDisabled hook Signed-off-by: Mark Dunphy --- .../EntityContextMenuItemBlueprint.tsx | 8 ++- .../catalog/src/alpha/contextMenuItems.tsx | 53 ++++++++++++++++--- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index b5c9896696..fc8dcb0330 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -30,11 +30,13 @@ export type FactoryHrefParams = useTitle: () => string; icon: React.JSX.Element; useHref: () => string; + useIsDisabled?: () => boolean; } | { useTitle: () => string; icon: React.JSX.Element; href: string; + useIsDisabled?: () => boolean; }; /** @alpha */ @@ -42,6 +44,7 @@ export type FactoryDialogParams = { useOnClick: () => React.MouseEventHandler; useTitle: () => string; icon: React.JSX.Element; + useIsDisabled?: () => boolean; }; /** @alpha */ @@ -73,9 +76,11 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ return ({ onClose }) => { const onClick = params.useOnClick(); const title = params.useTitle(); + const disabled = params.useIsDisabled?.() ?? false; return ( { onClose(); onClick(e); @@ -93,9 +98,10 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ return () => { const href = useHref(); const title = params.useTitle(); + const disabled = params.useIsDisabled?.() ?? false; return ( - + {params.icon} diff --git a/plugins/catalog/src/alpha/contextMenuItems.tsx b/plugins/catalog/src/alpha/contextMenuItems.tsx index f17e58c286..a5148dbfc6 100644 --- a/plugins/catalog/src/alpha/contextMenuItems.tsx +++ b/plugins/catalog/src/alpha/contextMenuItems.tsx @@ -15,15 +15,28 @@ */ import React from 'react'; -import { EntityContextMenuItemBlueprint } from '@backstage/plugin-catalog-react/alpha'; +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 } from '@backstage/core-plugin-api'; -import { useTranslationRef } from '@backstage/frontend-plugin-api'; +import { alertApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; +import { + dialogApiRef, + useTranslationRef, + type DialogApiDialog, +} from '@backstage/frontend-plugin-api'; import { catalogTranslationRef } from './translation'; -import { useSearchParams } from 'react-router-dom'; +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'; export const copyEntityUrlContextMenuItem = EntityContextMenuItemBlueprint.make( { @@ -86,10 +99,36 @@ export const unregisterEntityContextMenuItem = const { t } = useTranslationRef(catalogTranslationRef); return t('entityContextMenu.unregisterMenuTitle'); }, + useIsDisabled: () => { + const unregisterPermission = useEntityPermission( + catalogEntityDeletePermission, + ); + + return !unregisterPermission.allowed; + }, useOnClick: () => { - return () => { - // eslint-disable-next-line no-alert - window.alert('unregister clicked'); + const { entity } = useEntity(); + const dialogApi = useApi(dialogApiRef); + const navigate = useNavigate(); + const catalogRoute = useRouteRef(rootRouteRef); + const unregisterRedirectRoute = useRouteRef(unregisterRedirectRouteRef); + + return async () => { + dialogApi.showModal(({ dialog }: { dialog: DialogApiDialog }) => ( + dialog.close()} + onConfirm={() => { + dialog.close(); + navigate( + unregisterRedirectRoute + ? unregisterRedirectRoute() + : catalogRoute(), + ); + }} + /> + )); }; }, }, From d1071b75418c120f92887d8bc3208167565d7b4b Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Mon, 24 Mar 2025 09:48:24 -0400 Subject: [PATCH 12/28] fix some ci issues Signed-off-by: Mark Dunphy --- .../EntityContextMenuItemBlueprint.test.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx index 4f386d207e..9a56c08b94 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx @@ -22,24 +22,21 @@ describe('EntityContextMenuItemBlueprint', () => { const routeRef = createRouteRef(); const data = [ { - loader: async () =>
  • Test!
  • , - }, - { - title: 'Test', + useTitle: () => 'Test', href: '/somewhere', icon: Test, }, { - title: 'Test', - useHref() { + useTitle: () => 'Test', + useHref: () => { const r = useRouteRef(routeRef) ?? (() => '/somewhere'); return r(); }, icon: Test, }, { - title: 'TestDialog', - dialogLoader: async () => () =>
    test dialog
    , + useTitle: () => 'TestDialog', + useOnClick: () => () => true, icon: Test, }, ]; From 946c33b4ace27421258f1bea3c2eb937c30fc4f2 Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Mon, 24 Mar 2025 09:58:27 -0400 Subject: [PATCH 13/28] api reports Signed-off-by: Mark Dunphy --- plugins/catalog-react/report-alpha.api.md | 47 +++++++++++------- .../EntityContextMenuItemBlueprint.tsx | 3 ++ plugins/catalog/report-alpha.api.md | 49 ++++++++++++++++++- 3 files changed, 79 insertions(+), 20 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index a3d495bc2a..c10c982dcc 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -6,7 +6,6 @@ import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ComponentType } from 'react'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; -import { DialogApiDialog } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; @@ -88,6 +87,23 @@ export const catalogReactTranslationRef: TranslationRef< } >; +// @alpha (undocumented) +export type ContextMenuItemComponent = ( + props: ContextMenuItemProps, +) => React_2.JSX.Element; + +// @alpha (undocumented) +export const contextMenuItemComponentDataRef: ConfigurableExtensionDataRef< + ContextMenuItemComponent, + 'catalog.contextMenuItemComponent', + {} +>; + +// @alpha (undocumented) +export type ContextMenuItemProps = { + onClose: () => void; +}; + // @alpha (undocumented) export function convertLegacyEntityCardExtension( LegacyExtension: ComponentType<{}>, @@ -332,8 +348,8 @@ export const EntityContextMenuItemBlueprint: ExtensionBlueprint<{ name: undefined; params: EntityContextMenuItemParams; output: ConfigurableExtensionDataRef< - React_2.JSX.Element, - 'core.reactElement', + ContextMenuItemComponent, + 'catalog.contextMenuItemComponent', {} >; inputs: {}; @@ -344,7 +360,6 @@ export const EntityContextMenuItemBlueprint: ExtensionBlueprint<{ // @alpha (undocumented) export type EntityContextMenuItemParams = - | FactoryLoaderParams | FactoryHrefParams | FactoryDialogParams; @@ -418,31 +433,27 @@ export type EntityPredicateValue = // @alpha (undocumented) export type FactoryDialogParams = { - dialogLoader: () => Promise< - ({ dialog }: { dialog: DialogApiDialog }) => JSX.Element - >; - title: string; - icon: JSX.Element; + useOnClick: () => React_2.MouseEventHandler; + useTitle: () => string; + icon: React_2.JSX.Element; + useIsDisabled?: () => boolean; }; // @alpha (undocumented) export type FactoryHrefParams = | { - title: string; - icon: JSX.Element; + useTitle: () => string; + icon: React_2.JSX.Element; useHref: () => string; + useIsDisabled?: () => boolean; } | { - title: string; - icon: JSX.Element; + useTitle: () => string; + icon: React_2.JSX.Element; href: string; + useIsDisabled?: () => boolean; }; -// @alpha (undocumented) -export type FactoryLoaderParams = { - loader: () => Promise; -}; - // @alpha export function isOwnerOf(owner: Entity, entity: Entity): boolean; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index fc8dcb0330..89448680a2 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -52,14 +52,17 @@ export type EntityContextMenuItemParams = | FactoryHrefParams | FactoryDialogParams; +/** @alpha */ export type ContextMenuItemProps = { onClose: () => void; }; +/** @alpha */ export type ContextMenuItemComponent = ( props: ContextMenuItemProps, ) => React.JSX.Element; +/** @alpha */ export const contextMenuItemComponentDataRef = createExtensionDataRef().with({ id: 'catalog.contextMenuItemComponent', diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index 54f534a537..98fc27fc2c 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -7,10 +7,12 @@ import { AnyApiFactory } from '@backstage/frontend-plugin-api'; import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { ContextMenuItemComponent } from '@backstage/plugin-catalog-react/alpha'; import { defaultEntityContentGroups } from '@backstage/plugin-catalog-react/alpha'; 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 +861,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< + ContextMenuItemComponent, + 'catalog.contextMenuItemComponent', + {} + >; + inputs: {}; + params: EntityContextMenuItemParams; + }>; + 'entity-context-menu-item:catalog/inspect-entity': ExtensionDefinition<{ + kind: 'entity-context-menu-item'; + name: 'inspect-entity'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + ContextMenuItemComponent, + 'catalog.contextMenuItemComponent', + {} + >; + inputs: {}; + params: EntityContextMenuItemParams; + }>; + 'entity-context-menu-item:catalog/unregister-entity': ExtensionDefinition<{ + kind: 'entity-context-menu-item'; + name: 'unregister-entity'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + ContextMenuItemComponent, + 'catalog.contextMenuItemComponent', + {} + >; + inputs: {}; + params: EntityContextMenuItemParams; + }>; 'nav-item:catalog': ExtensionDefinition<{ kind: 'nav-item'; name: undefined; @@ -1004,8 +1045,12 @@ const _default: FrontendPlugin< optional: false; } >; - extraContextMenuItems: ExtensionInput< - ConfigurableExtensionDataRef, + contextMenuItems: ExtensionInput< + ConfigurableExtensionDataRef< + ContextMenuItemComponent, + 'catalog.contextMenuItemComponent', + {} + >, { singleton: false; optional: false; From 233afd2ef35eef556676c9363d30abe95ef86c53 Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Mon, 24 Mar 2025 11:42:56 -0400 Subject: [PATCH 14/28] add tests excercising the page using context menu items Signed-off-by: Mark Dunphy --- plugins/catalog/src/alpha/pages.test.tsx | 118 +++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/plugins/catalog/src/alpha/pages.test.tsx b/plugins/catalog/src/alpha/pages.test.tsx index ab81d93342..0d18dd9dfa 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,121 @@ describe('Entity page', () => { ); }); }); + + describe('Entity Page Context Menu', () => { + const onClickMock = jest.fn(); + beforeEach(() => { + onClickMock.mockReset(); + }); + + it.each([ + { useIsDisabled: () => true, href: '/somewhere' }, + { useIsDisabled: () => false, href: '/somewhere' }, + { useIsDisabled: () => true, useHref: () => '/somewhere' }, + { useIsDisabled: () => false, useHref: () => '/somewhere' }, + ])('should render an href based context menu item', async params => { + const menuItem = EntityContextMenuItemBlueprint.make({ + name: 'test-href', + params: { + useTitle: () => 'Test Title', + 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), + }, + }, + ); + + 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', + params.useIsDisabled().toString(), + ); + }); + }); + + it.each([ + { useIsDisabled: () => true, useOnClick: () => onClickMock }, + { useIsDisabled: () => false, useOnClick: () => onClickMock }, + ])('should render a useOnClick based context menu item', async params => { + const menuItem = EntityContextMenuItemBlueprint.make({ + name: 'test-href', + params: { + useTitle: () => 'Test Title', + icon: Test Icon, + ...params, + }, + }); + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogEntityPage), + ).add(menuItem); + const disabled = params.useIsDisabled(); + + renderInTestApp( + + {tester.reactElement()} + , + { + config: { + app: { + title: 'Custom app', + }, + backend: { baseUrl: 'http://localhost:7000' }, + }, + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }, + ); + + 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); + }); + }); + }); }); From 50ce1645abecb81b5765613371cc7db7173d48d1 Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Thu, 27 Mar 2025 10:20:37 -0400 Subject: [PATCH 15/28] implement useProps api Signed-off-by: Mark Dunphy --- plugins/catalog-react/report-alpha.api.md | 11 ++- .../EntityContextMenuItemBlueprint.test.tsx | 24 +++--- .../EntityContextMenuItemBlueprint.tsx | 54 ++++++------- .../catalog/src/alpha/contextMenuItems.tsx | 80 +++++++++---------- plugins/catalog/src/alpha/pages.test.tsx | 50 ++++++++---- 5 files changed, 114 insertions(+), 105 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index c10c982dcc..e67bbb537c 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -11,6 +11,8 @@ 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 { MenuItemProps } from '@material-ui/core/MenuItem'; +import { default as React_2 } from 'react'; import { ResourcePermission } from '@backstage/plugin-permission-common'; import { RouteRef } from '@backstage/frontend-plugin-api'; import { TranslationRef } from '@backstage/core-plugin-api/alpha'; @@ -359,9 +361,12 @@ export const EntityContextMenuItemBlueprint: ExtensionBlueprint<{ }>; // @alpha (undocumented) -export type EntityContextMenuItemParams = - | FactoryHrefParams - | FactoryDialogParams; +export type EntityContextMenuItemParams = { + useProps: () => Omit & { + onClick?: () => Promise; + }; + icon: React_2.JSX.Element; +}; // @alpha (undocumented) export const EntityHeaderBlueprint: ExtensionBlueprint<{ diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx index 9a56c08b94..29d6cc11d8 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx @@ -16,28 +16,24 @@ import React from 'react'; import { EntityContextMenuItemBlueprint } from './EntityContextMenuItemBlueprint'; -import { createRouteRef, useRouteRef } from '@backstage/frontend-plugin-api'; describe('EntityContextMenuItemBlueprint', () => { - const routeRef = createRouteRef(); const data = [ { - useTitle: () => 'Test', - href: '/somewhere', icon: Test, + useProps: () => ({ + title: 'Test', + href: '/somewhere', + component: 'a', + disabled: true, + }), }, { - useTitle: () => 'Test', - useHref: () => { - const r = useRouteRef(routeRef) ?? (() => '/somewhere'); - return r(); - }, - icon: Test, - }, - { - useTitle: () => 'TestDialog', - useOnClick: () => () => true, icon: Test, + useProps: () => ({ + title: 'Test', + onClick: async () => {}, + }), }, ]; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index 89448680a2..02e9428c87 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -20,7 +20,7 @@ import { createExtensionDataRef, ExtensionBoundary, } from '@backstage/frontend-plugin-api'; -import MenuItem from '@material-ui/core/MenuItem'; +import MenuItem, { MenuItemProps } from '@material-ui/core/MenuItem'; import ListItemIcon from '@material-ui/core/ListItemIcon'; import ListItemText from '@material-ui/core/ListItemText'; @@ -48,9 +48,12 @@ export type FactoryDialogParams = { }; /** @alpha */ -export type EntityContextMenuItemParams = - | FactoryHrefParams - | FactoryDialogParams; +export type EntityContextMenuItemParams = { + useProps: () => Omit & { + onClick?: () => Promise; + }; + icon: React.JSX.Element; +}; /** @alpha */ export type ContextMenuItemProps = { @@ -75,36 +78,25 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ output: [contextMenuItemComponentDataRef], *factory(params: EntityContextMenuItemParams, { node }) { const loader = async (): Promise => { - if ('useOnClick' in params) { - return ({ onClose }) => { - const onClick = params.useOnClick(); - const title = params.useTitle(); - const disabled = params.useIsDisabled?.() ?? false; + return ({ onClose }) => { + const { + children, + button, + title, + onClick: onClickProp, + ...menuItemProps + } = params.useProps(); + let onClick; - return ( - { - onClose(); - onClick(e); - }} - > - {params.icon} - - - ); - }; - } - - const useHref = 'useHref' in params ? params.useHref : () => params.href; - - return () => { - const href = useHref(); - const title = params.useTitle(); - const disabled = params.useIsDisabled?.() ?? false; + if (onClickProp !== undefined) { + onClick = async () => { + await onClickProp(); + onClose(); + }; + } return ( - + {params.icon} diff --git a/plugins/catalog/src/alpha/contextMenuItems.tsx b/plugins/catalog/src/alpha/contextMenuItems.tsx index a5148dbfc6..5c503313cf 100644 --- a/plugins/catalog/src/alpha/contextMenuItems.tsx +++ b/plugins/catalog/src/alpha/contextMenuItems.tsx @@ -43,11 +43,7 @@ export const copyEntityUrlContextMenuItem = EntityContextMenuItemBlueprint.make( name: 'copy-entity-url', params: { icon: , - useTitle: () => { - const { t } = useTranslationRef(catalogTranslationRef); - return t('entityContextMenu.copyURLMenuTitle'); - }, - useOnClick: () => { + useProps: () => { const [copyState, copyToClipboard] = useCopyToClipboard(); const alertApi = useApi(alertApiRef); const { t } = useTranslationRef(catalogTranslationRef); @@ -62,8 +58,11 @@ export const copyEntityUrlContextMenuItem = EntityContextMenuItemBlueprint.make( } }, [copyState, alertApi, t]); - return async () => { - copyToClipboard(window.location.toString()); + return { + title: t('entityContextMenu.copyURLMenuTitle'), + onClick: async () => { + copyToClipboard(window.location.toString()); + }, }; }, }, @@ -75,15 +74,15 @@ export const inspectEntityContextMenuItem = EntityContextMenuItemBlueprint.make( name: 'inspect-entity', params: { icon: , - useTitle: () => { - const { t } = useTranslationRef(catalogTranslationRef); - return t('entityContextMenu.inspectMenuTitle'); - }, - useOnClick: () => { + useProps: () => { const [_, setSearchParams] = useSearchParams(); + const { t } = useTranslationRef(catalogTranslationRef); - return () => { - setSearchParams('inspect'); + return { + title: t('entityContextMenu.inspectMenuTitle'), + onClick: async () => { + setSearchParams('inspect'); + }, }; }, }, @@ -95,40 +94,37 @@ export const unregisterEntityContextMenuItem = name: 'unregister-entity', params: { icon: , - useTitle: () => { - const { t } = useTranslationRef(catalogTranslationRef); - return t('entityContextMenu.unregisterMenuTitle'); - }, - useIsDisabled: () => { - const unregisterPermission = useEntityPermission( - catalogEntityDeletePermission, - ); - - return !unregisterPermission.allowed; - }, - useOnClick: () => { + 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 async () => { - dialogApi.showModal(({ dialog }: { dialog: DialogApiDialog }) => ( - dialog.close()} - onConfirm={() => { - dialog.close(); - navigate( - unregisterRedirectRoute - ? unregisterRedirectRoute() - : catalogRoute(), - ); - }} - /> - )); + return { + title: t('entityContextMenu.unregisterMenuTitle'), + disabled: !unregisterPermission.allowed, + onClick: async () => { + dialogApi.showModal(({ dialog }: { dialog: DialogApiDialog }) => ( + dialog.close()} + onConfirm={() => { + dialog.close(); + navigate( + unregisterRedirectRoute + ? unregisterRedirectRoute() + : catalogRoute(), + ); + }} + /> + )); + }, }; }, }, diff --git a/plugins/catalog/src/alpha/pages.test.tsx b/plugins/catalog/src/alpha/pages.test.tsx index 0d18dd9dfa..f5aeb7d196 100644 --- a/plugins/catalog/src/alpha/pages.test.tsx +++ b/plugins/catalog/src/alpha/pages.test.tsx @@ -605,15 +605,26 @@ describe('Entity page', () => { }); it.each([ - { useIsDisabled: () => true, href: '/somewhere' }, - { useIsDisabled: () => false, href: '/somewhere' }, - { useIsDisabled: () => true, useHref: () => '/somewhere' }, - { useIsDisabled: () => false, useHref: () => '/somewhere' }, + { + 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: { - useTitle: () => 'Test Title', icon: Test Icon, ...params, }, @@ -645,6 +656,7 @@ describe('Entity page', () => { }, }, ); + const { disabled } = params.useProps(); await waitFor(async () => { await userEvent.click(screen.getByTestId('menu-button')); @@ -652,21 +664,29 @@ describe('Entity page', () => { expect(screen.getByText('Test Icon')).toBeInTheDocument(); const anchor = screen.getByText('Test Title').closest('a'); expect(anchor).toHaveAttribute('href', '/somewhere'); - expect(anchor).toHaveAttribute( - 'aria-disabled', - params.useIsDisabled().toString(), - ); + expect(anchor).toHaveAttribute('aria-disabled', disabled.toString()); }); }); it.each([ - { useIsDisabled: () => true, useOnClick: () => onClickMock }, - { useIsDisabled: () => false, useOnClick: () => onClickMock }, - ])('should render a useOnClick based context menu item', async params => { + { + 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-href', + name: 'test-click', params: { - useTitle: () => 'Test Title', icon: Test Icon, ...params, }, @@ -674,7 +694,6 @@ describe('Entity page', () => { const tester = createExtensionTester( Object.assign({ namespace: 'catalog' }, catalogEntityPage), ).add(menuItem); - const disabled = params.useIsDisabled(); renderInTestApp( { }, ); + const { disabled } = params.useProps(); await waitFor(async () => { await userEvent.click(screen.getByTestId('menu-button')); expect(screen.getByText('Test Title')).toBeInTheDocument(); From c758f0fde75871488d9c071d8c32a3984c94bdad Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Thu, 27 Mar 2025 10:41:54 -0400 Subject: [PATCH 16/28] fix popover appearing off screen on initial open Signed-off-by: Mark Dunphy --- .../components/EntityContextMenu/EntityContextMenu.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index 6cbd52e3ae..6bde89c957 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -145,6 +145,9 @@ export function EntityContextMenu(props: EntityContextMenuProps) { anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} transformOrigin={{ vertical: 'top', horizontal: 'right' }} aria-labelledby="long-menu" + PaperProps={{ + style: { minWidth: 200 }, + }} > {extraItems} @@ -184,8 +187,11 @@ export function EntityContextMenu(props: EntityContextMenuProps) { ) : ( - contextMenuItems.map(ExtraMenuItem => ( - + contextMenuItems.map((ContextMenuItem, idx) => ( + )) )} From f2416c2ba6c2e855af13f3682031e1650b38ff7a Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Tue, 1 Apr 2025 16:03:52 -0400 Subject: [PATCH 17/28] remove unused param types Signed-off-by: Mark Dunphy --- plugins/catalog-react/report-alpha.api.md | 23 ------------------- .../EntityContextMenuItemBlueprint.tsx | 23 ------------------- .../src/alpha/blueprints/index.ts | 2 -- 3 files changed, 48 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index e67bbb537c..e1d62e1c45 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -436,29 +436,6 @@ export type EntityPredicateValue = $contains: EntityPredicateExpression; }; -// @alpha (undocumented) -export type FactoryDialogParams = { - useOnClick: () => React_2.MouseEventHandler; - useTitle: () => string; - icon: React_2.JSX.Element; - useIsDisabled?: () => boolean; -}; - -// @alpha (undocumented) -export type FactoryHrefParams = - | { - useTitle: () => string; - icon: React_2.JSX.Element; - useHref: () => string; - useIsDisabled?: () => boolean; - } - | { - useTitle: () => string; - icon: React_2.JSX.Element; - href: string; - useIsDisabled?: () => boolean; - }; - // @alpha export function isOwnerOf(owner: Entity, entity: Entity): boolean; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index 02e9428c87..75e8d1ff47 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -24,29 +24,6 @@ import MenuItem, { MenuItemProps } from '@material-ui/core/MenuItem'; import ListItemIcon from '@material-ui/core/ListItemIcon'; import ListItemText from '@material-ui/core/ListItemText'; -/** @alpha */ -export type FactoryHrefParams = - | { - useTitle: () => string; - icon: React.JSX.Element; - useHref: () => string; - useIsDisabled?: () => boolean; - } - | { - useTitle: () => string; - icon: React.JSX.Element; - href: string; - useIsDisabled?: () => boolean; - }; - -/** @alpha */ -export type FactoryDialogParams = { - useOnClick: () => React.MouseEventHandler; - useTitle: () => string; - icon: React.JSX.Element; - useIsDisabled?: () => boolean; -}; - /** @alpha */ export type EntityContextMenuItemParams = { useProps: () => Omit & { diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts index 004652f716..2707531f35 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -28,7 +28,5 @@ export { EntityContextMenuItemBlueprint, type ContextMenuItemProps, type ContextMenuItemComponent, - type FactoryHrefParams, - type FactoryDialogParams, type EntityContextMenuItemParams, } from './EntityContextMenuItemBlueprint'; From 8048fe0cd2bc9861406544d02c232d460ddcd4bd Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Mon, 7 Apr 2025 17:35:20 -0400 Subject: [PATCH 18/28] avoid exporting MUI types, update click handler to support async and sync functions. ensure onClick handler passed to MenuItem is sync Signed-off-by: Mark Dunphy --- .../EntityContextMenuItemBlueprint.tsx | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index 75e8d1ff47..2e46e38c2a 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -20,15 +20,26 @@ import { createExtensionDataRef, ExtensionBoundary, } from '@backstage/frontend-plugin-api'; -import MenuItem, { MenuItemProps } from '@material-ui/core/MenuItem'; +import MenuItem from '@material-ui/core/MenuItem'; import ListItemIcon from '@material-ui/core/ListItemIcon'; import ListItemText from '@material-ui/core/ListItemText'; +/** @alpha */ +export type UseProps = () => + | { + title: React.ReactNode; + href: string; + disabled?: boolean; + } + | { + title: React.ReactNode; + onClick: () => void | Promise; + disabled?: boolean; + }; + /** @alpha */ export type EntityContextMenuItemParams = { - useProps: () => Omit & { - onClick?: () => Promise; - }; + useProps: UseProps; icon: React.JSX.Element; }; @@ -56,24 +67,22 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ *factory(params: EntityContextMenuItemParams, { node }) { const loader = async (): Promise => { return ({ onClose }) => { - const { - children, - button, - title, - onClick: onClickProp, - ...menuItemProps - } = params.useProps(); - let onClick; + const { title, ...menuItemProps } = params.useProps(); + let handleClick = undefined; - if (onClickProp !== undefined) { - onClick = async () => { - await onClickProp(); - onClose(); + if ('onClick' in menuItemProps) { + handleClick = () => { + const result = menuItemProps.onClick(); + if (result instanceof Promise) { + result.then(onClose); + } else { + onClose(); + } }; } return ( - + {params.icon} From 3626bcf2e3797276a77a780852c465d91b1744f0 Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Mon, 7 Apr 2025 20:09:19 -0400 Subject: [PATCH 19/28] api reports Signed-off-by: Mark Dunphy --- plugins/catalog-react/report-alpha.api.md | 18 ++++++++++++++---- .../src/alpha/blueprints/index.ts | 1 + 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index e1d62e1c45..242aa82fa0 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -11,7 +11,6 @@ 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 { MenuItemProps } from '@material-ui/core/MenuItem'; import { default as React_2 } from 'react'; import { ResourcePermission } from '@backstage/plugin-permission-common'; import { RouteRef } from '@backstage/frontend-plugin-api'; @@ -362,9 +361,7 @@ export const EntityContextMenuItemBlueprint: ExtensionBlueprint<{ // @alpha (undocumented) export type EntityContextMenuItemParams = { - useProps: () => Omit & { - onClick?: () => Promise; - }; + useProps: UseProps; icon: React_2.JSX.Element; }; @@ -448,5 +445,18 @@ export function useEntityPermission( error?: Error; }; +// @alpha (undocumented) +export type UseProps = () => + | { + title: React_2.ReactNode; + href: string; + disabled?: boolean; + } + | { + title: React_2.ReactNode; + onClick: () => void | Promise; + disabled?: boolean; + }; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts index 2707531f35..89ee947c25 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -29,4 +29,5 @@ export { type ContextMenuItemProps, type ContextMenuItemComponent, type EntityContextMenuItemParams, + type UseProps, } from './EntityContextMenuItemBlueprint'; From 5ed70bf42eba2f0ce29618e1a5e1c4559e70ff01 Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Tue, 8 Apr 2025 08:06:57 -0400 Subject: [PATCH 20/28] Add example to changeset, remove instanceof Promise check Signed-off-by: Mark Dunphy --- .changeset/proud-dots-fry.md | 33 ++++++++++++++++++- .../EntityContextMenuItemBlueprint.tsx | 4 +-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/.changeset/proud-dots-fry.md b/.changeset/proud-dots-fry.md index 2b75355332..2895f3d720 100644 --- a/.changeset/proud-dots-fry.md +++ b/.changeset/proud-dots-fry.md @@ -3,4 +3,35 @@ '@backstage/plugin-catalog': minor --- -Adds `EntityContextMenuItemBlueprint` to enable extending the entity page's context menu +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/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index 2e46e38c2a..e76830eaf6 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -73,8 +73,8 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ if ('onClick' in menuItemProps) { handleClick = () => { const result = menuItemProps.onClick(); - if (result instanceof Promise) { - result.then(onClose); + if (result && 'finally' in result) { + result.finally(onClose); } else { onClose(); } From cc773582342c0ee30b4f17a4c81ea2a82fd0285d Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Tue, 8 Apr 2025 09:19:51 -0400 Subject: [PATCH 21/28] use context provider for handling context menu close Signed-off-by: Mark Dunphy --- plugins/catalog-react/report-alpha.api.md | 9 +-- plugins/catalog-react/report.api.md | 21 ++++++ .../EntityContextMenuItemBlueprint.tsx | 17 ++--- .../src/alpha/blueprints/index.ts | 1 - plugins/catalog-react/src/hooks/index.ts | 9 +++ .../src/hooks/useEntityContextMenu.test.tsx | 44 ++++++++++++ .../src/hooks/useEntityContextMenu.tsx | 72 +++++++++++++++++++ .../EntityContextMenu/EntityContextMenu.tsx | 12 ++-- 8 files changed, 159 insertions(+), 26 deletions(-) create mode 100644 plugins/catalog-react/src/hooks/useEntityContextMenu.test.tsx create mode 100644 plugins/catalog-react/src/hooks/useEntityContextMenu.tsx diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 242aa82fa0..6245c7c5c8 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -89,9 +89,7 @@ export const catalogReactTranslationRef: TranslationRef< >; // @alpha (undocumented) -export type ContextMenuItemComponent = ( - props: ContextMenuItemProps, -) => React_2.JSX.Element; +export type ContextMenuItemComponent = (props: {}) => React_2.JSX.Element; // @alpha (undocumented) export const contextMenuItemComponentDataRef: ConfigurableExtensionDataRef< @@ -100,11 +98,6 @@ export const contextMenuItemComponentDataRef: ConfigurableExtensionDataRef< {} >; -// @alpha (undocumented) -export type ContextMenuItemProps = { - onClose: () => void; -}; - // @alpha (undocumented) export function convertLegacyEntityCardExtension( LegacyExtension: ComponentType<{}>, diff --git a/plugins/catalog-react/report.api.md b/plugins/catalog-react/report.api.md index 1e5735bd86..ed7e1e4971 100644 --- a/plugins/catalog-react/report.api.md +++ b/plugins/catalog-react/report.api.md @@ -243,6 +243,24 @@ export type EntityAutocompletePickerProps< hidden?: boolean; }; +// @public (undocumented) +export type EntityContextMenuContextValue = { + onMenuClose: () => void; +}; + +// @public (undocumented) +export const EntityContextMenuProvider: ( + props: EntityContextMenuProviderProps, +) => React_2.JSX.Element; + +// @public (undocumented) +export interface EntityContextMenuProviderProps { + // (undocumented) + children: React_2.ReactNode; + // (undocumented) + onMenuClose: () => void; +} + // @public export const EntityDisplayName: (props: EntityDisplayNameProps) => JSX.Element; @@ -778,6 +796,9 @@ export function useEntity(): { entity: TEntity; }; +// @public (undocumented) +export function useEntityContextMenu(): EntityContextMenuContextValue; + // @public export function useEntityList< EntityFilters extends DefaultEntityFilters = DefaultEntityFilters, diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index e76830eaf6..c273fa9f4c 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -23,6 +23,7 @@ import { 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 = () => @@ -44,14 +45,7 @@ export type EntityContextMenuItemParams = { }; /** @alpha */ -export type ContextMenuItemProps = { - onClose: () => void; -}; - -/** @alpha */ -export type ContextMenuItemComponent = ( - props: ContextMenuItemProps, -) => React.JSX.Element; +export type ContextMenuItemComponent = (props: {}) => React.JSX.Element; /** @alpha */ export const contextMenuItemComponentDataRef = @@ -66,7 +60,8 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ output: [contextMenuItemComponentDataRef], *factory(params: EntityContextMenuItemParams, { node }) { const loader = async (): Promise => { - return ({ onClose }) => { + return () => { + const { onMenuClose } = useEntityContextMenu(); const { title, ...menuItemProps } = params.useProps(); let handleClick = undefined; @@ -74,9 +69,9 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ handleClick = () => { const result = menuItemProps.onClick(); if (result && 'finally' in result) { - result.finally(onClose); + result.finally(onMenuClose); } else { - onClose(); + onMenuClose(); } }; } diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts index 89ee947c25..65cc45c946 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -26,7 +26,6 @@ export type { EntityCardType } from './extensionData'; export { contextMenuItemComponentDataRef, EntityContextMenuItemBlueprint, - type ContextMenuItemProps, type ContextMenuItemComponent, type EntityContextMenuItemParams, type UseProps, diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts index befcce520d..96a7315ca5 100644 --- a/plugins/catalog-react/src/hooks/index.ts +++ b/plugins/catalog-react/src/hooks/index.ts @@ -35,6 +35,15 @@ export type { EntityListProviderProps, PaginationMode, } from './useEntityListProvider'; +export type { + EntityContextMenuContextValue, + EntityContextMenuProviderProps, +} from './useEntityContextMenu'; +export { + EntityContextMenuProvider, + useEntityContextMenu, +} from './useEntityContextMenu'; + export { useEntityTypeFilter } from './useEntityTypeFilter'; export { useRelatedEntities } from './useRelatedEntities'; export { useStarredEntities } from './useStarredEntities'; 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..a28c41caa9 --- /dev/null +++ b/plugins/catalog-react/src/hooks/useEntityContextMenu.test.tsx @@ -0,0 +1,44 @@ +/* + * 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 React from 'react'; +import { + EntityContextMenuProvider, + useEntityContextMenu, +} from './useEntityContextMenu'; +import { renderHook } from '@testing-library/react'; + +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.tsx b/plugins/catalog-react/src/hooks/useEntityContextMenu.tsx new file mode 100644 index 0000000000..46db699abd --- /dev/null +++ b/plugins/catalog-react/src/hooks/useEntityContextMenu.tsx @@ -0,0 +1,72 @@ +/* + * 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 React from 'react'; +import { + createVersionedContext, + createVersionedValueMap, + useVersionedContext, +} from '@backstage/version-bridge'; + +/** @public */ +export type EntityContextMenuContextValue = { + onMenuClose: () => void; +}; + +const EntityContextMenuContext = createVersionedContext<{ + 1: EntityContextMenuContextValue; +}>('entity-context-menu-context'); + +/** @public */ +export interface EntityContextMenuProviderProps { + children: React.ReactNode; + onMenuClose: () => void; +} + +/** @public */ +export const EntityContextMenuProvider = ( + props: EntityContextMenuProviderProps, +) => { + const { children, onMenuClose } = props; + const value = { onMenuClose }; + + return ( + + {children} + + ); +}; + +/** @public */ +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/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index 6bde89c957..a5a4f8d8cc 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -36,6 +36,7 @@ import useCopyToClipboard from 'react-use/esm/useCopyToClipboard'; import { catalogTranslationRef } from '../../alpha/translation'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import type { ContextMenuItemComponent } from '@backstage/plugin-catalog-react/alpha'; +import { EntityContextMenuProvider } from '@backstage/plugin-catalog-react'; /** @public */ export type EntityContextMenuClassKey = 'button'; @@ -187,12 +188,11 @@ export function EntityContextMenu(props: EntityContextMenuProps) { ) : ( - contextMenuItems.map((ContextMenuItem, idx) => ( - - )) + + {contextMenuItems.map((ContextMenuItem, idx) => ( + + ))} + )} From b60edcbe74c9ca27bdad4bf606316ea8c886d3e8 Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Tue, 8 Apr 2025 09:35:34 -0400 Subject: [PATCH 22/28] switch finally to then Signed-off-by: Mark Dunphy --- .../src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index c273fa9f4c..82d1028964 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -68,8 +68,8 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ if ('onClick' in menuItemProps) { handleClick = () => { const result = menuItemProps.onClick(); - if (result && 'finally' in result) { - result.finally(onMenuClose); + if (result && 'then' in result) { + result.then(onMenuClose, onMenuClose); } else { onMenuClose(); } From c9e4d8e5734dda185e5904e31697855879aee9ef Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Tue, 8 Apr 2025 10:14:52 -0400 Subject: [PATCH 23/28] replace contextMenuItemComponentDataRef with coreExtensionData.reactElement Signed-off-by: Mark Dunphy --- plugins/catalog-react/report-alpha.api.md | 14 ++--------- .../EntityContextMenuItemBlueprint.tsx | 23 ++++++------------- .../src/alpha/blueprints/index.ts | 2 -- plugins/catalog/report-alpha.api.md | 19 ++++++--------- .../components/EntityHeader/EntityHeader.tsx | 3 +-- plugins/catalog/src/alpha/pages.tsx | 5 ++-- .../EntityContextMenu/EntityContextMenu.tsx | 7 ++---- 7 files changed, 21 insertions(+), 52 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 6245c7c5c8..e70fc34ed8 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -88,16 +88,6 @@ export const catalogReactTranslationRef: TranslationRef< } >; -// @alpha (undocumented) -export type ContextMenuItemComponent = (props: {}) => React_2.JSX.Element; - -// @alpha (undocumented) -export const contextMenuItemComponentDataRef: ConfigurableExtensionDataRef< - ContextMenuItemComponent, - 'catalog.contextMenuItemComponent', - {} ->; - // @alpha (undocumented) export function convertLegacyEntityCardExtension( LegacyExtension: ComponentType<{}>, @@ -342,8 +332,8 @@ export const EntityContextMenuItemBlueprint: ExtensionBlueprint<{ name: undefined; params: EntityContextMenuItemParams; output: ConfigurableExtensionDataRef< - ContextMenuItemComponent, - 'catalog.contextMenuItemComponent', + React_2.JSX.Element, + 'core.reactElement', {} >; inputs: {}; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index 82d1028964..43bf4213f9 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -16,8 +16,8 @@ import React from 'react'; import { + coreExtensionData, createExtensionBlueprint, - createExtensionDataRef, ExtensionBoundary, } from '@backstage/frontend-plugin-api'; import MenuItem from '@material-ui/core/MenuItem'; @@ -44,23 +44,14 @@ export type EntityContextMenuItemParams = { icon: React.JSX.Element; }; -/** @alpha */ -export type ContextMenuItemComponent = (props: {}) => React.JSX.Element; - -/** @alpha */ -export const contextMenuItemComponentDataRef = - createExtensionDataRef().with({ - id: 'catalog.contextMenuItemComponent', - }); - /** @alpha */ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ kind: 'entity-context-menu-item', attachTo: { id: 'page:catalog/entity', input: 'contextMenuItems' }, - output: [contextMenuItemComponentDataRef], + output: [coreExtensionData.reactElement], *factory(params: EntityContextMenuItemParams, { node }) { - const loader = async (): Promise => { - return () => { + const loader = async () => { + const Component = () => { const { onMenuClose } = useEntityContextMenu(); const { title, ...menuItemProps } = params.useProps(); let handleClick = undefined; @@ -83,10 +74,10 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({
    ); }; + + return ; }; - yield contextMenuItemComponentDataRef( - ExtensionBoundary.lazyComponent(node, loader), - ); + 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 65cc45c946..b21522331d 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -24,9 +24,7 @@ export { EntityHeaderBlueprint } from './EntityHeaderBlueprint'; export { defaultEntityContentGroups } from './extensionData'; export type { EntityCardType } from './extensionData'; export { - contextMenuItemComponentDataRef, EntityContextMenuItemBlueprint, - type ContextMenuItemComponent, type EntityContextMenuItemParams, type UseProps, } from './EntityContextMenuItemBlueprint'; diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index 98fc27fc2c..54d4187536 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -7,7 +7,6 @@ import { AnyApiFactory } from '@backstage/frontend-plugin-api'; import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; -import { ContextMenuItemComponent } from '@backstage/plugin-catalog-react/alpha'; import { defaultEntityContentGroups } from '@backstage/plugin-catalog-react/alpha'; import { Entity } from '@backstage/catalog-model'; import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; @@ -867,8 +866,8 @@ const _default: FrontendPlugin< config: {}; configInput: {}; output: ConfigurableExtensionDataRef< - ContextMenuItemComponent, - 'catalog.contextMenuItemComponent', + JSX_2.Element, + 'core.reactElement', {} >; inputs: {}; @@ -880,8 +879,8 @@ const _default: FrontendPlugin< config: {}; configInput: {}; output: ConfigurableExtensionDataRef< - ContextMenuItemComponent, - 'catalog.contextMenuItemComponent', + JSX_2.Element, + 'core.reactElement', {} >; inputs: {}; @@ -893,8 +892,8 @@ const _default: FrontendPlugin< config: {}; configInput: {}; output: ConfigurableExtensionDataRef< - ContextMenuItemComponent, - 'catalog.contextMenuItemComponent', + JSX_2.Element, + 'core.reactElement', {} >; inputs: {}; @@ -1046,11 +1045,7 @@ const _default: FrontendPlugin< } >; contextMenuItems: ExtensionInput< - ConfigurableExtensionDataRef< - ContextMenuItemComponent, - 'catalog.contextMenuItemComponent', - {} - >, + ConfigurableExtensionDataRef, { singleton: false; optional: false; diff --git a/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx b/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx index d53e8d63f3..75e0baa113 100644 --- a/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx +++ b/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx @@ -55,7 +55,6 @@ import { import { EntityLabels } from '../EntityLabels'; import { EntityContextMenu } from '../../../components/EntityContextMenu'; import { rootRouteRef, unregisterRedirectRouteRef } from '../../../routes'; -import { ContextMenuItemComponent } from '@backstage/plugin-catalog-react/alpha'; function headerProps( paramKind: string | undefined, @@ -179,7 +178,7 @@ export function EntityHeader(props: { UNSTABLE_contextMenuOptions?: { disableUnregister: boolean | 'visible' | 'hidden' | 'disable'; }; - contextMenuItems?: ContextMenuItemComponent[]; + 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 diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index 64287dbd77..b39fe510e6 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -31,7 +31,6 @@ import { EntityHeaderBlueprint, EntityContentBlueprint, defaultEntityContentGroups, - contextMenuItemComponentDataRef, } from '@backstage/plugin-catalog-react/alpha'; import { rootRouteRef } from '../routes'; import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl'; @@ -73,7 +72,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ EntityContentBlueprint.dataRefs.filterExpression.optional(), EntityContentBlueprint.dataRefs.group.optional(), ]), - contextMenuItems: createExtensionInput([contextMenuItemComponentDataRef]), + contextMenuItems: createExtensionInput([coreExtensionData.reactElement]), }, config: { schema: { @@ -91,7 +90,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ const { EntityLayout } = await import('./components/EntityLayout'); const menuItems = inputs.contextMenuItems.map(item => - item.get(contextMenuItemComponentDataRef), + item.get(coreExtensionData.reactElement), ); type Groups = Record< diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index a5a4f8d8cc..fd8d76c052 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -35,7 +35,6 @@ 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 type { ContextMenuItemComponent } from '@backstage/plugin-catalog-react/alpha'; import { EntityContextMenuProvider } from '@backstage/plugin-catalog-react'; /** @public */ @@ -63,7 +62,7 @@ interface ExtraContextMenuItem { interface EntityContextMenuProps { UNSTABLE_extraContextMenuItems?: ExtraContextMenuItem[]; UNSTABLE_contextMenuOptions?: UnregisterEntityOptions; - contextMenuItems?: ContextMenuItemComponent[]; + contextMenuItems?: React.JSX.Element[]; onUnregisterEntity: () => void; onInspectEntity: () => void; } @@ -189,9 +188,7 @@ export function EntityContextMenu(props: EntityContextMenuProps) { ) : ( - {contextMenuItems.map((ContextMenuItem, idx) => ( - - ))} + {contextMenuItems} )} From 631c79cd3492126e676a7aaf2d9217179901d5e8 Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Tue, 8 Apr 2025 11:36:56 -0400 Subject: [PATCH 24/28] relocate context menu provider Signed-off-by: Mark Dunphy --- plugins/catalog-graph/report-alpha.api.md | 2 +- plugins/catalog-react/report.api.md | 21 --------- plugins/catalog-react/src/hooks/index.ts | 9 ---- .../src/hooks/useEntityContextMenu.test.tsx | 44 ------------------- .../src/hooks/useEntityContextMenu.ts | 41 +++++++++++++++++ plugins/catalog/package.json | 1 + .../EntityContextMenu/EntityContextMenu.tsx | 2 +- .../src/context/EntityContextMenuContext.tsx} | 27 ++---------- plugins/catalog/src/context/index.ts | 16 +++++++ yarn.lock | 1 + 10 files changed, 64 insertions(+), 100 deletions(-) delete mode 100644 plugins/catalog-react/src/hooks/useEntityContextMenu.test.tsx create mode 100644 plugins/catalog-react/src/hooks/useEntityContextMenu.ts rename plugins/{catalog-react/src/hooks/useEntityContextMenu.tsx => catalog/src/context/EntityContextMenuContext.tsx} (72%) create mode 100644 plugins/catalog/src/context/index.ts diff --git a/plugins/catalog-graph/report-alpha.api.md b/plugins/catalog-graph/report-alpha.api.md index cf5835e35c..1d906ebb68 100644 --- a/plugins/catalog-graph/report-alpha.api.md +++ b/plugins/catalog-graph/report-alpha.api.md @@ -126,9 +126,9 @@ const _default: FrontendPlugin< direction?: Direction | undefined; zoom?: 'disabled' | 'enabled' | 'enable-on-click' | undefined; relations?: string[] | undefined; - rootEntityRefs?: string[] | undefined; maxDepth?: number | undefined; kinds?: string[] | undefined; + rootEntityRefs?: string[] | undefined; unidirectional?: boolean | undefined; mergeRelations?: boolean | undefined; relationPairs?: [string, string][] | undefined; diff --git a/plugins/catalog-react/report.api.md b/plugins/catalog-react/report.api.md index ed7e1e4971..1e5735bd86 100644 --- a/plugins/catalog-react/report.api.md +++ b/plugins/catalog-react/report.api.md @@ -243,24 +243,6 @@ export type EntityAutocompletePickerProps< hidden?: boolean; }; -// @public (undocumented) -export type EntityContextMenuContextValue = { - onMenuClose: () => void; -}; - -// @public (undocumented) -export const EntityContextMenuProvider: ( - props: EntityContextMenuProviderProps, -) => React_2.JSX.Element; - -// @public (undocumented) -export interface EntityContextMenuProviderProps { - // (undocumented) - children: React_2.ReactNode; - // (undocumented) - onMenuClose: () => void; -} - // @public export const EntityDisplayName: (props: EntityDisplayNameProps) => JSX.Element; @@ -796,9 +778,6 @@ export function useEntity(): { entity: TEntity; }; -// @public (undocumented) -export function useEntityContextMenu(): EntityContextMenuContextValue; - // @public export function useEntityList< EntityFilters extends DefaultEntityFilters = DefaultEntityFilters, diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts index 96a7315ca5..befcce520d 100644 --- a/plugins/catalog-react/src/hooks/index.ts +++ b/plugins/catalog-react/src/hooks/index.ts @@ -35,15 +35,6 @@ export type { EntityListProviderProps, PaginationMode, } from './useEntityListProvider'; -export type { - EntityContextMenuContextValue, - EntityContextMenuProviderProps, -} from './useEntityContextMenu'; -export { - EntityContextMenuProvider, - useEntityContextMenu, -} from './useEntityContextMenu'; - export { useEntityTypeFilter } from './useEntityTypeFilter'; export { useRelatedEntities } from './useRelatedEntities'; export { useStarredEntities } from './useStarredEntities'; diff --git a/plugins/catalog-react/src/hooks/useEntityContextMenu.test.tsx b/plugins/catalog-react/src/hooks/useEntityContextMenu.test.tsx deleted file mode 100644 index a28c41caa9..0000000000 --- a/plugins/catalog-react/src/hooks/useEntityContextMenu.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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 React from 'react'; -import { - EntityContextMenuProvider, - useEntityContextMenu, -} from './useEntityContextMenu'; -import { renderHook } from '@testing-library/react'; - -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/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index fd8d76c052..640bb75e49 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -35,7 +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 '@backstage/plugin-catalog-react'; +import { EntityContextMenuProvider } from '../../context'; /** @public */ export type EntityContextMenuClassKey = 'button'; diff --git a/plugins/catalog-react/src/hooks/useEntityContextMenu.tsx b/plugins/catalog/src/context/EntityContextMenuContext.tsx similarity index 72% rename from plugins/catalog-react/src/hooks/useEntityContextMenu.tsx rename to plugins/catalog/src/context/EntityContextMenuContext.tsx index 46db699abd..b3769757b2 100644 --- a/plugins/catalog-react/src/hooks/useEntityContextMenu.tsx +++ b/plugins/catalog/src/context/EntityContextMenuContext.tsx @@ -17,10 +17,9 @@ import React from 'react'; import { createVersionedContext, createVersionedValueMap, - useVersionedContext, } from '@backstage/version-bridge'; -/** @public */ +/** @internal */ export type EntityContextMenuContextValue = { onMenuClose: () => void; }; @@ -29,13 +28,13 @@ const EntityContextMenuContext = createVersionedContext<{ 1: EntityContextMenuContextValue; }>('entity-context-menu-context'); -/** @public */ +/** @internal */ export interface EntityContextMenuProviderProps { children: React.ReactNode; onMenuClose: () => void; } -/** @public */ +/** @internal */ export const EntityContextMenuProvider = ( props: EntityContextMenuProviderProps, ) => { @@ -50,23 +49,3 @@ export const EntityContextMenuProvider = ( ); }; - -/** @public */ -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/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" From 0560d63c7f2cf38fa58d7df02d7828482c7c5672 Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Tue, 8 Apr 2025 12:35:45 -0400 Subject: [PATCH 25/28] fix catalog-graph api report Signed-off-by: Mark Dunphy --- plugins/catalog-graph/report-alpha.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-graph/report-alpha.api.md b/plugins/catalog-graph/report-alpha.api.md index 1d906ebb68..cf5835e35c 100644 --- a/plugins/catalog-graph/report-alpha.api.md +++ b/plugins/catalog-graph/report-alpha.api.md @@ -126,9 +126,9 @@ const _default: FrontendPlugin< direction?: Direction | undefined; zoom?: 'disabled' | 'enabled' | 'enable-on-click' | undefined; relations?: string[] | undefined; + rootEntityRefs?: string[] | undefined; maxDepth?: number | undefined; kinds?: string[] | undefined; - rootEntityRefs?: string[] | undefined; unidirectional?: boolean | undefined; mergeRelations?: boolean | undefined; relationPairs?: [string, string][] | undefined; From f84401f9fa5cda900835f7674bcc67d993c9fa08 Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Tue, 8 Apr 2025 12:43:08 -0400 Subject: [PATCH 26/28] add useEntityContextMenu test Signed-off-by: Mark Dunphy --- .../src/hooks/useEntityContextMenu.test.tsx | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 plugins/catalog-react/src/hooks/useEntityContextMenu.test.tsx 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..e2b45a0fc9 --- /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 React 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: React.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); + }); +}); From 60c2ec168df5fbef558c5819dbf2ed270a03d57e Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Tue, 8 Apr 2025 13:20:53 -0400 Subject: [PATCH 27/28] remove React imports and run api reports Signed-off-by: Mark Dunphy --- plugins/catalog-react/report-alpha.api.md | 14 +++++--------- .../EntityContextMenuItemBlueprint.test.tsx | 2 -- .../blueprints/EntityContextMenuItemBlueprint.tsx | 8 ++++---- .../src/hooks/useEntityContextMenu.test.tsx | 4 ++-- plugins/catalog/report.api.md | 4 ++-- plugins/catalog/src/alpha/contextMenuItems.tsx | 4 ++-- .../src/context/EntityContextMenuContext.tsx | 4 ++-- 7 files changed, 17 insertions(+), 23 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index e70fc34ed8..b9a064043c 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -11,7 +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 { default as React_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'; @@ -331,11 +331,7 @@ export const EntityContextMenuItemBlueprint: ExtensionBlueprint<{ kind: 'entity-context-menu-item'; name: undefined; params: EntityContextMenuItemParams; - output: ConfigurableExtensionDataRef< - React_2.JSX.Element, - 'core.reactElement', - {} - >; + output: ConfigurableExtensionDataRef; inputs: {}; config: {}; configInput: {}; @@ -345,7 +341,7 @@ export const EntityContextMenuItemBlueprint: ExtensionBlueprint<{ // @alpha (undocumented) export type EntityContextMenuItemParams = { useProps: UseProps; - icon: React_2.JSX.Element; + icon: JSX_2.Element; }; // @alpha (undocumented) @@ -431,12 +427,12 @@ export function useEntityPermission( // @alpha (undocumented) export type UseProps = () => | { - title: React_2.ReactNode; + title: ReactNode; href: string; disabled?: boolean; } | { - title: React_2.ReactNode; + title: ReactNode; onClick: () => void | Promise; disabled?: boolean; }; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx index 29d6cc11d8..3bdfc00db7 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx @@ -13,8 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import React from 'react'; import { EntityContextMenuItemBlueprint } from './EntityContextMenuItemBlueprint'; describe('EntityContextMenuItemBlueprint', () => { diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index 43bf4213f9..f43dd6a6d9 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import { ReactNode, JSX } from 'react'; import { coreExtensionData, createExtensionBlueprint, @@ -28,12 +28,12 @@ import { useEntityContextMenu } from '../../hooks/useEntityContextMenu'; /** @alpha */ export type UseProps = () => | { - title: React.ReactNode; + title: ReactNode; href: string; disabled?: boolean; } | { - title: React.ReactNode; + title: ReactNode; onClick: () => void | Promise; disabled?: boolean; }; @@ -41,7 +41,7 @@ export type UseProps = () => /** @alpha */ export type EntityContextMenuItemParams = { useProps: UseProps; - icon: React.JSX.Element; + icon: JSX.Element; }; /** @alpha */ diff --git a/plugins/catalog-react/src/hooks/useEntityContextMenu.test.tsx b/plugins/catalog-react/src/hooks/useEntityContextMenu.test.tsx index e2b45a0fc9..60757a6050 100644 --- a/plugins/catalog-react/src/hooks/useEntityContextMenu.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityContextMenu.test.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import { ReactNode } from 'react'; import { useEntityContextMenu } from './useEntityContextMenu'; import { renderHook } from '@testing-library/react'; import { @@ -29,7 +29,7 @@ const Provider = ({ children, onMenuClose, }: { - children: React.ReactNode; + children: ReactNode; onMenuClose: () => void; }) => ( diff --git a/plugins/catalog/report.api.md b/plugins/catalog/report.api.md index 3ddf164802..0059f0504f 100644 --- a/plugins/catalog/report.api.md +++ b/plugins/catalog/report.api.md @@ -403,9 +403,9 @@ export interface EntityLayoutProps { // (undocumented) children?: ReactNode; // (undocumented) - NotFoundComponent?: ReactNode; - // (undocumented) extraMenuItems?: JSX.Element[]; + // (undocumented) + NotFoundComponent?: ReactNode; parentEntityRelations?: string[]; // Warning: (ae-forgotten-export) The symbol "EntityContextMenuOptions" needs to be exported by the entry point index.d.ts // diff --git a/plugins/catalog/src/alpha/contextMenuItems.tsx b/plugins/catalog/src/alpha/contextMenuItems.tsx index 5c503313cf..d503bc33a1 100644 --- a/plugins/catalog/src/alpha/contextMenuItems.tsx +++ b/plugins/catalog/src/alpha/contextMenuItems.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import React from 'react'; import { EntityContextMenuItemBlueprint, useEntityPermission, @@ -37,6 +36,7 @@ import { } 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( { @@ -48,7 +48,7 @@ export const copyEntityUrlContextMenuItem = EntityContextMenuItemBlueprint.make( const alertApi = useApi(alertApiRef); const { t } = useTranslationRef(catalogTranslationRef); - React.useEffect(() => { + useEffect(() => { if (!copyState.error && copyState.value) { alertApi.post({ message: t('entityContextMenu.copiedMessage'), diff --git a/plugins/catalog/src/context/EntityContextMenuContext.tsx b/plugins/catalog/src/context/EntityContextMenuContext.tsx index b3769757b2..ec2f823b07 100644 --- a/plugins/catalog/src/context/EntityContextMenuContext.tsx +++ b/plugins/catalog/src/context/EntityContextMenuContext.tsx @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; import { createVersionedContext, createVersionedValueMap, } from '@backstage/version-bridge'; +import { ReactNode } from 'react'; /** @internal */ export type EntityContextMenuContextValue = { @@ -30,7 +30,7 @@ const EntityContextMenuContext = createVersionedContext<{ /** @internal */ export interface EntityContextMenuProviderProps { - children: React.ReactNode; + children: ReactNode; onMenuClose: () => void; } From 41ff384049f8bb3461c0dd8c63cb70af9356a1a8 Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Tue, 8 Apr 2025 13:25:37 -0400 Subject: [PATCH 28/28] removed unused prop Signed-off-by: Mark Dunphy --- plugins/catalog/report.api.md | 2 -- plugins/catalog/src/components/EntityLayout/EntityLayout.tsx | 1 - 2 files changed, 3 deletions(-) diff --git a/plugins/catalog/report.api.md b/plugins/catalog/report.api.md index 0059f0504f..10e120d208 100644 --- a/plugins/catalog/report.api.md +++ b/plugins/catalog/report.api.md @@ -403,8 +403,6 @@ export interface EntityLayoutProps { // (undocumented) children?: ReactNode; // (undocumented) - extraMenuItems?: JSX.Element[]; - // (undocumented) NotFoundComponent?: ReactNode; parentEntityRelations?: string[]; // Warning: (ae-forgotten-export) The symbol "EntityContextMenuOptions" needs to be exported by the entry point index.d.ts diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index f088a2d320..bae1f7bd0c 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -181,7 +181,6 @@ export interface EntityLayoutProps { UNSTABLE_contextMenuOptions?: EntityContextMenuOptions; children?: ReactNode; NotFoundComponent?: ReactNode; - extraMenuItems?: 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