Merge pull request #29823 from marknotfound/filter-entity-context-menu-item
EntityContextMenuItemBluePrint: Add a filter param and config option
This commit is contained in:
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
+200
-1
@@ -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),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user