handle context menu item filtering in the entity page

Signed-off-by: Mark Dunphy <markd@spotify.com>
This commit is contained in:
Mark Dunphy
2025-05-09 12:04:35 -04:00
parent 8be30fe4fb
commit 590bd5fefe
6 changed files with 232 additions and 54 deletions
+28 -2
View File
@@ -338,7 +338,22 @@ 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;
}
>
| 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)
@@ -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],
@@ -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 (
<MenuItem {...menuItemProps} onClick={handleClick}>
<ListItemIcon>{params.icon}</ListItemIcon>
@@ -120,5 +101,7 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({
};
yield coreExtensionData.reactElement(ExtensionBoundary.lazy(node, loader));
yield* resolveEntityFilterData(params.filter, config, node);
},
});
+63 -16
View File
@@ -869,11 +869,22 @@ const _default: FrontendPlugin<
configInput: {
filter?: EntityPredicate | undefined;
};
output: ConfigurableExtensionDataRef<
JSX_2.Element,
'core.reactElement',
{}
>;
output:
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| 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<JSX_2.Element, 'core.reactElement', {}>
| 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<JSX_2.Element, 'core.reactElement', {}>
| 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<JSX_2.Element, 'core.reactElement', {}>,
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'catalog.entity-filter-expression',
{
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();
});
},
);
});
});
+25 -8
View File
@@ -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,
) ?? <EntityHeader contextMenuItems={menuItems} />;
);
let groups = Object.entries(defaultEntityContentGroups).reduce<Groups>(
(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 (
<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