handle context menu item filtering in the entity page
Signed-off-by: Mark Dunphy <markd@spotify.com>
This commit is contained in:
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user