filter menu items by entity predicate, filter string, or function

Signed-off-by: Mark Dunphy <markd@spotify.com>
This commit is contained in:
Mark Dunphy
2025-05-02 12:11:12 -04:00
parent af0ab1cb78
commit f001924d84
2 changed files with 249 additions and 59 deletions
@@ -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: <span>Icon</span>,
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: <span>Icon</span>,
...params,
useProps: () => ({
title: 'Test',
onClick: () => {},
}),
},
});
renderInTestApp(
<EntityProvider
entity={{
apiVersion: 'v1',
kind: 'API',
metadata: { name: 'test' },
}}
>
<ul>{createExtensionTester(extension).reactElement()}</ul>
</EntityProvider>,
);
renderInTestApp(
<EntityProvider
entity={{
apiVersion: 'v1',
kind: 'API',
metadata: { name: 'test' },
}}
>
<ul>{createExtensionTester(extension, { config }).reactElement()}</ul>
</EntityProvider>,
);
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: <span>Icon</span>,
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: <span>Icon</span>,
...params,
useProps: () => ({
title: 'Test',
onClick: () => {},
}),
},
});
renderInTestApp(
<EntityProvider
entity={{
apiVersion: 'v1',
kind: 'Component',
metadata: { name: 'test' },
}}
>
<ul>{createExtensionTester(extension).reactElement()}</ul>
</EntityProvider>,
);
renderInTestApp(
<EntityProvider
entity={{
apiVersion: 'v1',
kind: 'Component',
metadata: { name: 'test' },
}}
>
<ul>{createExtensionTester(extension, { config }).reactElement()}</ul>
</EntityProvider>,
);
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({
@@ -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;
}