Merge pull request #29823 from marknotfound/filter-entity-context-menu-item

EntityContextMenuItemBluePrint: Add a filter param and config option
This commit is contained in:
Patrik Oldsberg
2025-05-15 15:33:01 +02:00
committed by GitHub
7 changed files with 425 additions and 38 deletions
+6
View File
@@ -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.
+23 -4
View File
@@ -332,17 +332,36 @@ export const EntityContextMenuItemBlueprint: ExtensionBlueprint<{
kind: 'entity-context-menu-item';
name: undefined;
params: EntityContextMenuItemParams;
output: ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>;
output:
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| 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)
@@ -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: <span>Icon</span>,
useProps: () => ({
title: 'Test',
onClick: () => {},
}),
},
});
renderInTestApp(
<EntityProvider
entity={{
apiVersion: 'v1',
kind: 'Component',
metadata: { name: 'test' },
}}
>
<ul>{createExtensionTester(extension).reactElement()}</ul>
</EntityProvider>,
);
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: <span>Icon</span>,
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);
});
});
@@ -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),
);
}
},
});
+53 -22
View File
@@ -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<JSX_2.Element, 'core.reactElement', {}>
| 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<JSX_2.Element, 'core.reactElement', {}>
| 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<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{
optional: true;
}
>;
inputs: {};
params: EntityContextMenuItemParams;
}>;
@@ -1045,7 +1069,14 @@ const _default: FrontendPlugin<
}
>;
contextMenuItems: ExtensionInput<
ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>,
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
+87
View File
@@ -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: <span>Test Icon</span>,
useProps: () => ({
onClick: onClickMock,
title: 'Should Render',
}),
...positive.params,
},
});
const filteredMenuItem = EntityContextMenuItemBlueprint.make({
name: 'should-not-render-menu-item',
params: {
icon: <span>Test Icon</span>,
useProps: () => ({
onClick: onClickMock,
title: 'Should Not Render',
}),
...negative.params,
},
});
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
)
.add(menuItem)
.add(filteredMenuItem);
renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
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();
});
},
);
});
});
+23 -8
View File
@@ -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,
) ?? <EntityHeader contextMenuItems={menuItems} />;
);
let groups = Object.entries(defaultEntityContentGroups).reduce<Groups>(
(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 (
<AsyncEntityProvider {...useEntityFromUrl()}>
<EntityLayout header={header} contextMenuItems={menuItems}>
<AsyncEntityProvider {...entityFromUrl}>
<EntityLayout
header={header}
contextMenuItems={filteredMenuItems}
>
{Object.values(groups).flatMap(({ title, items }) =>
items.map(output => (
<EntityLayout.Route