diff --git a/.changeset/orange-women-fry.md b/.changeset/orange-women-fry.md new file mode 100644 index 0000000000..493a1aed57 --- /dev/null +++ b/.changeset/orange-women-fry.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog-react': patch +'@backstage/plugin-catalog': patch +--- + +A new `filter` parameter has been added to `EntityContextMenuItemBlueprint` to make it easier to configure which entities a menu item should appear for. The `filter` parameter is a function which accepts an entity and returns a boolean. diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index d5f85b7836..46d3030140 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -332,17 +332,36 @@ 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; + } + >; inputs: {}; - config: {}; - configInput: {}; - dataRefs: never; + config: { + filter: EntityPredicate | undefined; + }; + configInput: { + filter?: EntityPredicate | undefined; + }; + dataRefs: { + filterFunction: ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + {} + >; + }; }>; // @alpha (undocumented) export type EntityContextMenuItemParams = { useProps: UseProps; icon: JSX_2.Element; + filter?: EntityPredicate | ((entity: Entity) => boolean); }; // @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 3bdfc00db7..55598f5de1 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx @@ -13,7 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { + createExtensionTester, + renderInTestApp, +} from '@backstage/frontend-test-utils'; import { EntityContextMenuItemBlueprint } from './EntityContextMenuItemBlueprint'; +import { screen, waitFor } from '@testing-library/react'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { Entity } from '@backstage/catalog-model'; + +jest.mock('../../hooks/useEntityContextMenu', () => ({ + useEntityContextMenu: () => ({ + onMenuClose: jest.fn(), + }), +})); describe('EntityContextMenuItemBlueprint', () => { const data = [ @@ -49,7 +62,131 @@ describe('EntityContextMenuItemBlueprint', () => { "id": "page:catalog/entity", "input": "contextMenuItems", }, - "configSchema": undefined, + "configSchema": { + "parse": [Function], + "schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "filter": { + "anyOf": [ + { + "anyOf": [ + { + "type": [ + "string", + "number", + "boolean", + ], + }, + { + "items": { + "$ref": "#/properties/filter/anyOf/0/anyOf/0", + }, + "type": "array", + }, + ], + }, + { + "additionalProperties": false, + "properties": { + "$all": { + "items": { + "$ref": "#/properties/filter", + }, + "type": "array", + }, + }, + "required": [ + "$all", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$any": { + "items": { + "$ref": "#/properties/filter", + }, + "type": "array", + }, + }, + "required": [ + "$any", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$not": { + "$ref": "#/properties/filter", + }, + }, + "required": [ + "$not", + ], + "type": "object", + }, + { + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/properties/filter/anyOf/0", + }, + { + "additionalProperties": false, + "properties": { + "$exists": { + "type": "boolean", + }, + }, + "required": [ + "$exists", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$in": { + "items": { + "$ref": "#/properties/filter/anyOf/0/anyOf/0", + }, + "type": "array", + }, + }, + "required": [ + "$in", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$contains": { + "$ref": "#/properties/filter", + }, + }, + "required": [ + "$contains", + ], + "type": "object", + }, + ], + }, + "propertyNames": { + "pattern": "^(?!\\$).*$", + }, + "type": "object", + }, + ], + }, + }, + "type": "object", + }, + }, "disabled": false, "factory": [Function], "inputs": {}, @@ -57,6 +194,15 @@ describe('EntityContextMenuItemBlueprint', () => { "name": "test", "output": [ [Function], + { + "$$type": "@backstage/ExtensionDataRef", + "config": { + "optional": true, + }, + "id": "catalog.entity-filter-function", + "optional": [Function], + "toString": [Function], + }, ], "override": [Function], "toString": [Function], @@ -64,4 +210,57 @@ describe('EntityContextMenuItemBlueprint', () => { } `); }); + + it('should render a menu item', async () => { + const extension = EntityContextMenuItemBlueprint.make({ + name: 'test', + params: { + icon: Icon, + useProps: () => ({ + title: 'Test', + onClick: () => {}, + }), + }, + }); + + renderInTestApp( + +
    {createExtensionTester(extension).reactElement()}
+
, + ); + + await waitFor(() => { + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + }); + + it.each([ + { filter: { kind: 'Api' } }, + { filter: (e: Entity) => e.kind.toLowerCase() === 'api' }, + ])('should return a filter function', async ({ filter }) => { + const extension = EntityContextMenuItemBlueprint.make({ + name: 'test', + params: { + icon: Icon, + useProps: () => ({ title: 'Test', onClick: () => {} }), + filter, + }, + }); + + const tester = createExtensionTester(extension); + + const filterFn = tester.get( + EntityContextMenuItemBlueprint.dataRefs.filterFunction, + ); + + expect(filterFn).toBeDefined(); + expect(filterFn?.({ kind: 'Api' } as Entity)).toBe(true); + expect(filterFn?.({ kind: 'Component' } as Entity)).toBe(false); + }); }); diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index f43dd6a6d9..470aa13646 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -24,7 +24,13 @@ 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'; - +import { + EntityPredicate, + entityPredicateToFilterFunction, +} from '../predicates'; +import type { Entity } from '@backstage/catalog-model'; +import { entityFilterFunctionDataRef } from './extensionData'; +import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema'; /** @alpha */ export type UseProps = () => | { @@ -42,14 +48,26 @@ export type UseProps = () => export type EntityContextMenuItemParams = { useProps: UseProps; icon: JSX.Element; + filter?: EntityPredicate | ((entity: Entity) => boolean); }; /** @alpha */ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ kind: 'entity-context-menu-item', attachTo: { id: 'page:catalog/entity', input: 'contextMenuItems' }, - output: [coreExtensionData.reactElement], - *factory(params: EntityContextMenuItemParams, { node }) { + output: [ + coreExtensionData.reactElement, + entityFilterFunctionDataRef.optional(), + ], + dataRefs: { + filterFunction: entityFilterFunctionDataRef, + }, + config: { + schema: { + filter: z => createEntityPredicateSchema(z).optional(), + }, + }, + *factory(params: EntityContextMenuItemParams, { node, config }) { const loader = async () => { const Component = () => { const { onMenuClose } = useEntityContextMenu(); @@ -79,5 +97,17 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ }; yield coreExtensionData.reactElement(ExtensionBoundary.lazy(node, loader)); + + if (config.filter) { + yield entityFilterFunctionDataRef( + entityPredicateToFilterFunction(config.filter), + ); + } else if (typeof params.filter === 'function') { + yield entityFilterFunctionDataRef(params.filter); + } else if (params.filter) { + yield entityFilterFunctionDataRef( + entityPredicateToFilterFunction(params.filter), + ); + } }, }); diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index 54d4187536..7ef20d8228 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -863,39 +863,63 @@ const _default: FrontendPlugin< 'entity-context-menu-item:catalog/copy-entity-url': ExtensionDefinition<{ kind: 'entity-context-menu-item'; name: 'copy-entity-url'; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - JSX_2.Element, - 'core.reactElement', - {} - >; + config: { + filter: EntityPredicate | undefined; + }; + configInput: { + filter?: EntityPredicate | undefined; + }; + output: + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + { + optional: true; + } + >; inputs: {}; params: EntityContextMenuItemParams; }>; 'entity-context-menu-item:catalog/inspect-entity': ExtensionDefinition<{ kind: 'entity-context-menu-item'; name: 'inspect-entity'; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - JSX_2.Element, - 'core.reactElement', - {} - >; + config: { + filter: EntityPredicate | undefined; + }; + configInput: { + filter?: EntityPredicate | undefined; + }; + output: + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + { + optional: true; + } + >; inputs: {}; params: EntityContextMenuItemParams; }>; 'entity-context-menu-item:catalog/unregister-entity': ExtensionDefinition<{ kind: 'entity-context-menu-item'; name: 'unregister-entity'; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - JSX_2.Element, - 'core.reactElement', - {} - >; + config: { + filter: EntityPredicate | undefined; + }; + configInput: { + filter?: EntityPredicate | undefined; + }; + output: + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + { + optional: true; + } + >; inputs: {}; params: EntityContextMenuItemParams; }>; @@ -1045,7 +1069,14 @@ const _default: FrontendPlugin< } >; contextMenuItems: ExtensionInput< - ConfigurableExtensionDataRef, + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + { + 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..47b3efbd0a 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 b39fe510e6..c1f5b55341 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -31,11 +31,11 @@ import { EntityHeaderBlueprint, EntityContentBlueprint, defaultEntityContentGroups, + EntityContextMenuItemBlueprint, } from '@backstage/plugin-catalog-react/alpha'; import { rootRouteRef } from '../routes'; import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl'; import { buildFilterFn } from './filter/FilterWrapper'; -import { EntityHeader } from './components/EntityHeader'; export const catalogPage = PageBlueprint.makeWithOverrides({ inputs: { @@ -72,7 +72,10 @@ 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(), + ]), }, config: { schema: { @@ -89,9 +92,12 @@ 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: + item.get(EntityContextMenuItemBlueprint.dataRefs.filterFunction) ?? + (() => true), + })); type Groups = Record< string, @@ -100,7 +106,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ const header = inputs.header?.get( EntityHeaderBlueprint.dataRefs.element, - ) ?? ; + ); let groups = Object.entries(defaultEntityContentGroups).reduce( (rest, group) => { @@ -137,9 +143,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 => (