diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx index 81272de8b4..694e79bb8c 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.test.tsx @@ -20,6 +20,7 @@ import { 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: () => ({ @@ -47,6 +48,25 @@ describe('EntityContextMenuItemBlueprint', () => { }, ]; + const filterTestCases = [ + { + params: { filter: 'kind:component' }, + config: undefined, + }, + { + params: { filter: (e: Entity) => e.kind.toLowerCase() === 'component' }, + config: undefined, + }, + { + params: {}, + config: { filter: 'kind:component' }, + }, + { + params: {}, + config: { filter: { kind: 'component' } }, + }, + ]; + it.each(data)('should return an extension with sane defaults', params => { const extension = EntityContextMenuItemBlueprint.make({ name: 'test', @@ -61,7 +81,138 @@ 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": [ + { + "type": "string", + }, + { + "anyOf": [ + { + "anyOf": [ + { + "type": [ + "string", + "number", + "boolean", + ], + }, + { + "items": { + "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0", + }, + "type": "array", + }, + ], + }, + { + "additionalProperties": false, + "properties": { + "$all": { + "items": { + "$ref": "#/properties/filter/anyOf/1", + }, + "type": "array", + }, + }, + "required": [ + "$all", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$any": { + "items": { + "$ref": "#/properties/filter/anyOf/1", + }, + "type": "array", + }, + }, + "required": [ + "$any", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$not": { + "$ref": "#/properties/filter/anyOf/1", + }, + }, + "required": [ + "$not", + ], + "type": "object", + }, + { + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/properties/filter/anyOf/1/anyOf/0", + }, + { + "additionalProperties": false, + "properties": { + "$exists": { + "type": "boolean", + }, + }, + "required": [ + "$exists", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$in": { + "items": { + "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0", + }, + "type": "array", + }, + }, + "required": [ + "$in", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$contains": { + "$ref": "#/properties/filter/anyOf/1", + }, + }, + "required": [ + "$contains", + ], + "type": "object", + }, + ], + }, + "propertyNames": { + "pattern": "^(?!\\$).*$", + }, + "type": "object", + }, + ], + }, + ], + }, + }, + "type": "object", + }, + }, "disabled": false, "factory": [Function], "inputs": {}, @@ -77,65 +228,71 @@ describe('EntityContextMenuItemBlueprint', () => { `); }); - it('should exclude items based on the filter function', async () => { - const extension = EntityContextMenuItemBlueprint.make({ - name: 'test', - params: { - icon: Icon, - filter: e => e.kind.toLowerCase() === 'component', - useProps: () => ({ - title: 'Test', - onClick: () => {}, - }), - }, - }); + it.each(filterTestCases)( + 'should exclude items based on the filter', + async ({ params, config }) => { + const extension = EntityContextMenuItemBlueprint.make({ + name: 'test', + params: { + icon: Icon, + ...params, + useProps: () => ({ + title: 'Test', + onClick: () => {}, + }), + }, + }); - renderInTestApp( - - - , - ); + renderInTestApp( + + + , + ); - await waitFor(() => { - expect(screen.queryByText('Test')).not.toBeInTheDocument(); - }); - }); + await waitFor(() => { + expect(screen.queryByText('Test')).not.toBeInTheDocument(); + }); + }, + ); - it('should include items based on the filter function', async () => { - const extension = EntityContextMenuItemBlueprint.make({ - name: 'test', - params: { - icon: Icon, - filter: e => e.kind.toLowerCase() === 'component', - useProps: () => ({ - title: 'Test', - onClick: () => {}, - }), - }, - }); + it.each(filterTestCases)( + 'should include items based on the filter', + async ({ params, config }) => { + const extension = EntityContextMenuItemBlueprint.make({ + name: 'test', + params: { + icon: Icon, + ...params, + useProps: () => ({ + title: 'Test', + onClick: () => {}, + }), + }, + }); - renderInTestApp( - - - , - ); + renderInTestApp( + + + , + ); - await waitFor(() => { - expect(screen.getByText('Test')).toBeInTheDocument(); - }); - }); + await waitFor(() => { + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + }, + ); it('should render a menu item', async () => { const extension = EntityContextMenuItemBlueprint.make({ diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index d2ce2b3cce..acc3ddbc48 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -24,9 +24,16 @@ 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 } 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 = () => | { @@ -44,7 +51,7 @@ export type UseProps = () => export type EntityContextMenuItemParams = { useProps: UseProps; icon: JSX.Element; - filter?: (entity: Entity) => boolean; + filter?: string | EntityPredicate | ((entity: Entity) => boolean); }; /** @alpha */ @@ -52,7 +59,33 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ kind: 'entity-context-menu-item', attachTo: { id: 'page:catalog/entity', input: 'contextMenuItems' }, output: [coreExtensionData.reactElement], - *factory(params: EntityContextMenuItemParams, { node }) { + config: { + schema: { + filter: z => + z.union([z.string(), createEntityPredicateSchema(z)]).optional(), + }, + }, + *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(); @@ -71,7 +104,7 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ }; } - if (entity && params.filter && !params.filter(entity)) { + if (entity && !filter(entity)) { return null; }