From 590bd5fefe971c510861c26c6bbd5c9cdbaeff88 Mon Sep 17 00:00:00 2001 From: Mark Dunphy Date: Fri, 9 May 2025 12:04:35 -0400 Subject: [PATCH] handle context menu item filtering in the entity page Signed-off-by: Mark Dunphy --- plugins/catalog-react/report-alpha.api.md | 30 ++++++- .../EntityContextMenuItemBlueprint.test.tsx | 18 ++++ .../EntityContextMenuItemBlueprint.tsx | 39 +++------ plugins/catalog/report-alpha.api.md | 79 +++++++++++++---- plugins/catalog/src/alpha/pages.test.tsx | 87 +++++++++++++++++++ plugins/catalog/src/alpha/pages.tsx | 33 +++++-- 6 files changed, 232 insertions(+), 54 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 595741ab67..bb296e7c28 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -338,7 +338,22 @@ export const EntityContextMenuItemBlueprint: ExtensionBlueprint<{ kind: 'entity-context-menu-item'; name: undefined; params: EntityContextMenuItemParams; - output: ConfigurableExtensionDataRef; + output: + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + { + optional: true; + } + > + | ConfigurableExtensionDataRef< + string, + 'catalog.entity-filter-expression', + { + optional: true; + } + >; inputs: {}; config: { filter: EntityPredicate | undefined; @@ -346,7 +361,18 @@ export const EntityContextMenuItemBlueprint: ExtensionBlueprint<{ configInput: { filter?: EntityPredicate | undefined; }; - dataRefs: never; + dataRefs: { + filterFunction: ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + {} + >; + filterExpression: ConfigurableExtensionDataRef< + string, + 'catalog.entity-filter-expression', + {} + >; + }; }>; // @alpha (undocumented) diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx index 694e79bb8c..2491b714b1 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx @@ -220,6 +220,24 @@ describe('EntityContextMenuItemBlueprint', () => { "name": "test", "output": [ [Function], + { + "$$type": "@backstage/ExtensionDataRef", + "config": { + "optional": true, + }, + "id": "catalog.entity-filter-function", + "optional": [Function], + "toString": [Function], + }, + { + "$$type": "@backstage/ExtensionDataRef", + "config": { + "optional": true, + }, + "id": "catalog.entity-filter-expression", + "optional": [Function], + "toString": [Function], + }, ], "override": [Function], "toString": [Function], diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index acc3ddbc48..59cd0b48e4 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -26,14 +26,12 @@ import ListItemText from '@material-ui/core/ListItemText'; import { useEntityContextMenu } from '../../hooks/useEntityContextMenu'; import { EntityPredicate } from '../predicates'; import type { Entity } from '@backstage/catalog-model'; -import { useEntity } from '../../hooks/useEntity'; import { entityFilterExpressionDataRef, entityFilterFunctionDataRef, } from './extensionData'; import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema'; import { resolveEntityFilterData } from './resolveEntityFilterData'; -import { buildFilterFn } from '../filter/FilterWrapper'; /** @alpha */ export type UseProps = () => | { @@ -58,7 +56,15 @@ export type EntityContextMenuItemParams = { export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ kind: 'entity-context-menu-item', attachTo: { id: 'page:catalog/entity', input: 'contextMenuItems' }, - output: [coreExtensionData.reactElement], + output: [ + coreExtensionData.reactElement, + entityFilterFunctionDataRef.optional(), + entityFilterExpressionDataRef.optional(), + ], + dataRefs: { + filterFunction: entityFilterFunctionDataRef, + filterExpression: entityFilterExpressionDataRef, + }, config: { schema: { filter: z => @@ -66,30 +72,9 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ }, }, *factory(params: EntityContextMenuItemParams, { node, config }) { - const resolvedFilterData = []; - - for (const resolved of resolveEntityFilterData( - params.filter, - config, - node, - )) { - resolvedFilterData.push(resolved); - } - - const resolvedFilter = resolvedFilterData.pop(); - const filter = buildFilterFn( - resolvedFilter?.id === entityFilterFunctionDataRef.id - ? resolvedFilter.value - : undefined, - resolvedFilter?.id === entityFilterExpressionDataRef.id - ? resolvedFilter.value - : undefined, - ); - const loader = async () => { const Component = () => { const { onMenuClose } = useEntityContextMenu(); - const { entity } = useEntity(); const { title, ...menuItemProps } = params.useProps(); let handleClick = undefined; @@ -104,10 +89,6 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ }; } - if (entity && !filter(entity)) { - return null; - } - return ( {params.icon} @@ -120,5 +101,7 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ }; yield coreExtensionData.reactElement(ExtensionBoundary.lazy(node, loader)); + + yield* resolveEntityFilterData(params.filter, config, node); }, }); diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index 246c7929f0..93a4e94225 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -869,11 +869,22 @@ const _default: FrontendPlugin< configInput: { filter?: EntityPredicate | undefined; }; - output: ConfigurableExtensionDataRef< - JSX_2.Element, - 'core.reactElement', - {} - >; + output: + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + { + optional: true; + } + > + | ConfigurableExtensionDataRef< + string, + 'catalog.entity-filter-expression', + { + optional: true; + } + >; inputs: {}; params: EntityContextMenuItemParams; }>; @@ -886,11 +897,22 @@ const _default: FrontendPlugin< configInput: { filter?: EntityPredicate | undefined; }; - output: ConfigurableExtensionDataRef< - JSX_2.Element, - 'core.reactElement', - {} - >; + output: + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + { + optional: true; + } + > + | ConfigurableExtensionDataRef< + string, + 'catalog.entity-filter-expression', + { + optional: true; + } + >; inputs: {}; params: EntityContextMenuItemParams; }>; @@ -903,11 +925,22 @@ const _default: FrontendPlugin< configInput: { filter?: EntityPredicate | undefined; }; - output: ConfigurableExtensionDataRef< - JSX_2.Element, - 'core.reactElement', - {} - >; + output: + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + { + optional: true; + } + > + | ConfigurableExtensionDataRef< + string, + 'catalog.entity-filter-expression', + { + optional: true; + } + >; inputs: {}; params: EntityContextMenuItemParams; }>; @@ -1057,7 +1090,21 @@ const _default: FrontendPlugin< } >; contextMenuItems: ExtensionInput< - ConfigurableExtensionDataRef, + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + { + optional: true; + } + > + | ConfigurableExtensionDataRef< + string, + 'catalog.entity-filter-expression', + { + optional: true; + } + >, { singleton: false; optional: false; diff --git a/plugins/catalog/src/alpha/pages.test.tsx b/plugins/catalog/src/alpha/pages.test.tsx index f5aeb7d196..6e5d3fab95 100644 --- a/plugins/catalog/src/alpha/pages.test.tsx +++ b/plugins/catalog/src/alpha/pages.test.tsx @@ -36,6 +36,7 @@ import { } from '@backstage/plugin-catalog-react'; import { convertLegacyRouteRef } from '@backstage/core-compat-api'; import { rootRouteRef } from '../routes'; +import { Entity } from '@backstage/catalog-model'; describe('Entity page', () => { const entityMock = { @@ -733,5 +734,91 @@ describe('Entity page', () => { expect(onClickMock).toHaveBeenCalledTimes(disabled ? 0 : 1); }); }); + + it.each([ + { + positive: { params: {} }, + negative: { params: { filter: 'kind:api' } }, + }, + { + positive: { params: { filter: 'kind:component' } }, + negative: { params: { filter: 'kind:api' } }, + }, + { + positive: { + params: { + filter: (e: Entity) => e.kind.toLowerCase() === 'component', + }, + }, + negative: { + params: { filter: (e: Entity) => e.kind.toLowerCase() === 'api' }, + }, + }, + ])( + 'should render menu items according to filters', + async ({ positive, negative }) => { + const menuItem = EntityContextMenuItemBlueprint.make({ + name: 'should-render-menu-item', + params: { + icon: Test Icon, + useProps: () => ({ + onClick: onClickMock, + title: 'Should Render', + }), + ...positive.params, + }, + }); + + const filteredMenuItem = EntityContextMenuItemBlueprint.make({ + name: 'should-not-render-menu-item', + params: { + icon: Test Icon, + useProps: () => ({ + onClick: onClickMock, + title: 'Should Not Render', + }), + ...negative.params, + }, + }); + + const tester = createExtensionTester( + Object.assign({ namespace: 'catalog' }, catalogEntityPage), + ) + .add(menuItem) + .add(filteredMenuItem); + + 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('Should Render')).toBeInTheDocument(); + expect( + screen.queryByText('Should Not Render'), + ).not.toBeInTheDocument(); + }); + }, + ); }); }); diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index f822cb0fed..3708ae11fa 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -32,10 +32,10 @@ import { EntityContentBlueprint, defaultEntityContentGroups, buildFilterFn, + EntityContextMenuItemBlueprint, } from '@backstage/plugin-catalog-react/alpha'; import { rootRouteRef } from '../routes'; import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl'; -import { EntityHeader } from './components/EntityHeader'; export const catalogPage = PageBlueprint.makeWithOverrides({ inputs: { @@ -72,7 +72,11 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ EntityContentBlueprint.dataRefs.filterExpression.optional(), EntityContentBlueprint.dataRefs.group.optional(), ]), - contextMenuItems: createExtensionInput([coreExtensionData.reactElement]), + contextMenuItems: createExtensionInput([ + coreExtensionData.reactElement, + EntityContextMenuItemBlueprint.dataRefs.filterFunction.optional(), + EntityContextMenuItemBlueprint.dataRefs.filterExpression.optional(), + ]), }, config: { schema: { @@ -89,9 +93,13 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ loader: async () => { const { EntityLayout } = await import('./components/EntityLayout'); - const menuItems = inputs.contextMenuItems.map(item => - item.get(coreExtensionData.reactElement), - ); + const menuItems = inputs.contextMenuItems.map(item => ({ + element: item.get(coreExtensionData.reactElement), + filter: buildFilterFn( + item.get(EntityContextMenuItemBlueprint.dataRefs.filterFunction), + item.get(EntityContextMenuItemBlueprint.dataRefs.filterExpression), + ), + })); type Groups = Record< string, @@ -100,7 +108,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ const header = inputs.header?.get( EntityHeaderBlueprint.dataRefs.element, - ) ?? ; + ); let groups = Object.entries(defaultEntityContentGroups).reduce( (rest, group) => { @@ -137,9 +145,18 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ } const Component = () => { + const entityFromUrl = useEntityFromUrl(); + const { entity } = entityFromUrl; + const filteredMenuItems = entity + ? menuItems.filter(i => i.filter(entity)).map(i => i.element) + : []; + return ( - - + + {Object.values(groups).flatMap(({ title, items }) => items.map(output => (