Merge pull request #28951 from backstage/rugvip/predicates

catalog-react: add support for entity predicates for filtering in new frontend system
This commit is contained in:
Patrik Oldsberg
2025-03-06 08:56:14 +01:00
committed by GitHub
31 changed files with 1398 additions and 137 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': minor
---
Add support for a new entity predicate syntax when defining `filter`s related to the blueprints exported via `/alpha` for the new frontend system. For more information, see the [entity filters documentation](https://backstage.io/docs/features/software-catalog/catalog-customization#advanced-customization#entity-filters).
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/config-loader': minor
---
The include transforms applied during config loading will now only apply to the known keys `$file`, `$env`, and `$include`. Any other key that begins with a `$` will now be passed through as is.
@@ -502,3 +502,201 @@ const routes = (
</FlatRoutes>
);
```
## New Frontend System
This section of the documentation explains how to create and configure catalog extensions in the [new frontend system](../../frontend-system/index.md).
:::warning Warning
This section is a work in progress.
:::
### Entity filters
Many extensions that attach within the catalog entity pages accept a `filter` configuration. The purpose of the `filter` configuration is to select what entities the extension should be applied to or be present on. Many of these extension will have a default filter defined, but you can override it by providing your own. When defining filters in code you can use either a predicate function or a entity predicate query, while in configuration you can only use an entity predicate query.
### Entity predicate queries
The entity predicate syntax is a minimal JSON-based query language for filtering catalog entities. It is loosely inspired by the [MongoDB query syntax](https://www.mongodb.com/docs/manual/tutorial/query-documents/), behaving roughly the same way but with a different set of operators.
The most simple entity predicate is an object expression with key-value mappings where the key is the full dot-separated path to the value in the entity, and the value is the value to do a case insensitive match against. Each entry in this object is evaluated separately, but all of them must match for the overall predicate to result in a match. For example, the following will match any component entities of the type `service`:
```json
{
"filter": {
"kind": "component",
"spec.type": "service"
}
}
```
Or when utilizing YAML syntax:
```yaml
filter:
kind: component
spec.type: service
```
In addition to this basic syntax, entity predicates support logical operators that can be nested and applied around these object expressions. For example, the following will match all components entities that are of type `service` or `website`:
```json
{
"filter": {
"$all": [
{
"kind": "component"
},
{
"$any": [{ "spec.type": "service" }, { "spec.type": "website" }]
}
]
}
}
```
Or when utilizing YAML syntax:
```yaml
filter:
$all:
- kind: component
- $any:
- spec.type: service
- spec.type: website
```
Finally, entity predicates also support value operators that can be used in place of the values in the object expression. For example, the following is a simpler way to express the previous example:
```json
{
"filter": {
{
"kind": "component",
"spec.type": { "$in": ["service", "website"] }
},
}
}
```
Or when utilizing YAML syntax:
```yaml
filter:
kind: component
spec.type:
$in: [service, website]
```
### Entity predicate logical operators
The following section lists all logical operators for entity predicates.
#### `$all`
The `$all` operator has the following syntax:
```json
{ $all: [ { <expression1> }, { <expression2> }, ...] }
```
The `$all` operator evaluates to `true` if all expressions within the provided array evaluate to `true`. This includes an empty array, which means that `{ "$all": [] }` always evaluates to `true`.
```yaml title="Example usage of $all"
filter:
$all:
- kind: component
- $not:
spec.type: service
```
#### `$any`
The `$any` operator has the following syntax:
```json
{ $any: [ { <expression1> }, { <expression2> }, ...] }
```
The `$any` operator evaluates to `true` if at least one of the expressions within the provided array evaluate to `true`. This includes an empty array, which means that `{ "$any": [] }` always evaluates to `false`.
```yaml title="Example usage of $any"
filter:
$any:
- kind: component
- metadata.annotations.github.com/project-slug: { $exists: true }
```
#### `$not`
The `$not` operator has the following syntax:
```json
{ $not: { <expression> } }
```
The `$not` operator inverts the result of the provided express. If the expression evaluates to `true` then `$not` will evaluate to false, and the other way around.
```yaml title="Example usage of $not"
filter:
$not:
kind: template
```
### Entity predicate value operators
The following section lists all value operators for entity predicates.
#### `$exists`
The `$exists` operator has the following syntax:
```json
{ field: { $exists: <boolean> } }
```
The `$exists` operator will evaluate to `true` if the existence of the value it matches against matches the provided boolean. That is `{ $exists: true }` will evaluate to `true` if and only if the value is defined, and `{ $exists: false }` will evaluate to `true` if and only if the value is not defined.
```yaml title="Example usage of $exists"
filter:
metadata.annotations.github.com/project-slug: { $exists: true }
```
#### `$in`
The `$in` operator has the following syntax:
```json
{ field: { $in: [ <primitive1>, <primitive2>, ... ] } }
```
The `$in` operator will evaluate to `true` if the value it is matched against is exists within the array of primitives. The comparison is case insensitive and can only be done across primitive values. If the value matched against is an object or array, the operator will always evaluate to `false`.
```yaml title="Example usage of $in"
filter:
kind:
$in: [component, api]
```
#### `$contains`
The `$contains` operator has the following syntax:
```json
{ field: { $contains: { <expression> } } }
```
The `$contains` operator will evaluate to `true` if the value it is matched against is an array, and at least one of the elements in the array fully matches the provided expression. If the value matched against is not an array, or if the array is empty, the operator will always evaluate to `false`.
The expression used to match against the array can be any valid entity predicate expression, including logical operators and value operators.
```yaml title="Example usage of $contains"
filter:
relations:
$contains:
type: ownedBy
target:
$in: [group:default/admins, group:default/viewers]
```
+5 -1
View File
@@ -37,7 +37,11 @@ app:
- entity-card:catalog/labels
- entity-card:catalog/links:
config:
filter: kind:component has:links
filter:
kind: component
metadata.links:
$exists: true
# filter: kind:component has:links
type: info
# - entity-card:linguist/languages
- entity-card:catalog-graph/relations:
@@ -21,6 +21,8 @@ import { isObject } from './utils';
import { TransformFunc, ReadFileFunc } from './types';
import { SubstitutionFunc } from '../types';
const INCLUDE_KEYS = ['$file', '$env', '$include'];
// Parsers for each type of included file
const includeFileParser: {
[ext in string]: (content: string) => Promise<JsonObject>;
@@ -48,7 +50,9 @@ export function createIncludeTransform(
}
// Check if there's any key that starts with a '$', in that case we treat
// this entire object as an include description.
const [includeKey] = Object.keys(input).filter(key => key.startsWith('$'));
const [includeKey] = Object.keys(input).filter(key =>
INCLUDE_KEYS.includes(key),
);
if (includeKey) {
if (Object.keys(input).length !== 1) {
throw new Error(
+25 -24
View File
@@ -9,6 +9,7 @@ import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { EntityCardType } from '@backstage/plugin-catalog-react/alpha';
import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
@@ -45,11 +46,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'consumed-apis';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -82,7 +83,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -90,11 +91,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'consuming-components';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -127,7 +128,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -135,11 +136,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'definition';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -172,7 +173,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -180,11 +181,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'has-apis';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -217,7 +218,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -225,11 +226,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'provided-apis';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -262,7 +263,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -270,11 +271,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'providing-components';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -307,7 +308,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -317,11 +318,11 @@ const _default: FrontendPlugin<
config: {
path: string | undefined;
title: string | undefined;
filter: string | undefined;
filter: EntityPredicate | undefined;
group: string | false | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
title?: string | undefined;
path?: string | undefined;
group?: string | false | undefined;
@@ -379,7 +380,7 @@ const _default: FrontendPlugin<
| 'observability'
| undefined;
routeRef?: RouteRef<AnyRouteRefParams> | undefined;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
};
}>;
'entity-content:api-docs/definition': ExtensionDefinition<{
@@ -388,11 +389,11 @@ const _default: FrontendPlugin<
config: {
path: string | undefined;
title: string | undefined;
filter: string | undefined;
filter: EntityPredicate | undefined;
group: string | false | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
title?: string | undefined;
path?: string | undefined;
group?: string | false | undefined;
@@ -450,7 +451,7 @@ const _default: FrontendPlugin<
| 'observability'
| undefined;
routeRef?: RouteRef<AnyRouteRefParams> | undefined;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
};
}>;
'nav-item:api-docs': ExtensionDefinition<{
+4 -3
View File
@@ -9,6 +9,7 @@ import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Direction } from '@backstage/plugin-catalog-graph';
import { Entity } from '@backstage/catalog-model';
import { EntityCardType } from '@backstage/plugin-catalog-react/alpha';
import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
@@ -43,7 +44,7 @@ const _default: FrontendPlugin<
title: string | undefined;
height: number | undefined;
} & {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
@@ -59,7 +60,7 @@ const _default: FrontendPlugin<
mergeRelations?: boolean | undefined;
relationPairs?: [string, string][] | undefined;
} & {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -102,7 +103,7 @@ const _default: FrontendPlugin<
name: 'relations';
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
+2 -1
View File
@@ -102,7 +102,8 @@
"react": "^18.0.2",
"react-dom": "^18.0.2",
"react-router-dom": "^6.3.0",
"react-test-renderer": "^16.13.1"
"react-test-renderer": "^16.13.1",
"zod": "^3.22.4"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0",
+54 -15
View File
@@ -11,6 +11,7 @@ import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { JsonValue } from '@backstage/types';
import { JSX as JSX_2 } from 'react';
import { default as React_2 } from 'react';
import { ResourcePermission } from '@backstage/plugin-permission-common';
@@ -80,9 +81,7 @@ export function convertLegacyEntityCardExtension(
LegacyExtension: ComponentType<{}>,
overrides?: {
name?: string;
filter?:
| typeof EntityCardBlueprint.dataRefs.filterFunction.T
| typeof EntityCardBlueprint.dataRefs.filterExpression.T;
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
},
): ExtensionDefinition;
@@ -91,9 +90,7 @@ export function convertLegacyEntityContentExtension(
LegacyExtension: ComponentType<{}>,
overrides?: {
name?: string;
filter?:
| typeof EntityContentBlueprint.dataRefs.filterFunction.T
| typeof EntityContentBlueprint.dataRefs.filterExpression.T;
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
defaultPath?: string;
defaultTitle?: string;
},
@@ -113,7 +110,7 @@ export const EntityCardBlueprint: ExtensionBlueprint<{
name: undefined;
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
output:
@@ -141,11 +138,11 @@ export const EntityCardBlueprint: ExtensionBlueprint<{
>;
inputs: {};
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
dataRefs: {
@@ -186,7 +183,7 @@ export const EntityContentBlueprint: ExtensionBlueprint<{
| 'observability'
| undefined;
routeRef?: RouteRef<AnyRouteRefParams> | undefined;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
};
output:
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
@@ -224,11 +221,11 @@ export const EntityContentBlueprint: ExtensionBlueprint<{
config: {
path: string | undefined;
title: string | undefined;
filter: string | undefined;
filter: EntityPredicate | undefined;
group: string | false | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
title?: string | undefined;
path?: string | undefined;
group?: string | false | undefined;
@@ -262,7 +259,7 @@ export const EntityContentLayoutBlueprint: ExtensionBlueprint<{
kind: 'entity-content-layout';
name: undefined;
params: {
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
loader: () => Promise<
(props: EntityContentLayoutProps) => React_2.JSX.Element
>;
@@ -290,10 +287,10 @@ export const EntityContentLayoutBlueprint: ExtensionBlueprint<{
inputs: {};
config: {
type: string | undefined;
filter: string | undefined;
filter: EntityPredicate | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: string | undefined;
};
dataRefs: {
@@ -324,6 +321,48 @@ export interface EntityContentLayoutProps {
}>;
}
// @alpha (undocumented)
export type EntityPredicate =
| EntityPredicateExpression
| EntityPredicatePrimitive
| {
$all: EntityPredicate[];
}
| {
$any: EntityPredicate[];
}
| {
$not: EntityPredicate;
};
// @alpha (undocumented)
export type EntityPredicateExpression = {
[KPath in string]: EntityPredicateValue;
} & {
[KPath in `$${string}`]: never;
};
// @alpha (undocumented)
export type EntityPredicatePrimitive = string | number | boolean;
// @alpha
export function entityPredicateToFilterFunction<T extends JsonValue>(
entityPredicate: EntityPredicate,
): (value: T) => boolean;
// @alpha (undocumented)
export type EntityPredicateValue =
| EntityPredicatePrimitive
| {
$exists: boolean;
}
| {
$in: EntityPredicatePrimitive[];
}
| {
$contains: EntityPredicateExpression;
};
// @alpha
export function isOwnerOf(owner: Entity, entity: Entity): boolean;
@@ -52,7 +52,126 @@ describe('EntityCardBlueprint', () => {
"additionalProperties": false,
"properties": {
"filter": {
"type": "string",
"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": {
"enum": [
@@ -26,6 +26,10 @@ import {
entityCardTypes,
EntityCardType,
} from './extensionData';
import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema';
import { EntityPredicate } from '../predicates';
import { resolveEntityFilterData } from './resolveEntityFilterData';
import { Entity } from '@backstage/catalog-model';
/**
* @alpha
@@ -47,7 +51,8 @@ export const EntityCardBlueprint = createExtensionBlueprint({
},
config: {
schema: {
filter: z => z.string().optional(),
filter: z =>
z.union([z.string(), createEntityPredicateSchema(z)]).optional(),
type: z => z.enum(entityCardTypes).optional(),
},
},
@@ -58,22 +63,14 @@ export const EntityCardBlueprint = createExtensionBlueprint({
type,
}: {
loader: () => Promise<JSX.Element>;
filter?:
| typeof entityFilterFunctionDataRef.T
| typeof entityFilterExpressionDataRef.T;
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
type?: EntityCardType;
},
{ node, config },
) {
yield coreExtensionData.reactElement(ExtensionBoundary.lazy(node, loader));
if (config.filter) {
yield entityFilterExpressionDataRef(config.filter);
} else if (typeof filter === 'string') {
yield entityFilterExpressionDataRef(filter);
} else if (typeof filter === 'function') {
yield entityFilterFunctionDataRef(filter);
}
yield* resolveEntityFilterData(filter, config, node);
const finalType = config.type ?? type;
if (finalType) {
@@ -54,7 +54,126 @@ describe('EntityContentBlueprint', () => {
"additionalProperties": false,
"properties": {
"filter": {
"type": "string",
"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",
},
],
},
],
},
"group": {
"anyOf": [
@@ -27,6 +27,10 @@ import {
entityContentGroupDataRef,
defaultEntityContentGroups,
} from './extensionData';
import { EntityPredicate } from '../predicates';
import { resolveEntityFilterData } from './resolveEntityFilterData';
import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema';
import { Entity } from '@backstage/catalog-model';
/**
* @alpha
@@ -54,7 +58,8 @@ export const EntityContentBlueprint = createExtensionBlueprint({
schema: {
path: z => z.string().optional(),
title: z => z.string().optional(),
filter: z => z.string().optional(),
filter: z =>
z.union([z.string(), createEntityPredicateSchema(z)]).optional(),
group: z => z.literal(false).or(z.string()).optional(),
},
},
@@ -72,9 +77,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({
defaultTitle: string;
defaultGroup?: keyof typeof defaultEntityContentGroups | (string & {});
routeRef?: RouteRef;
filter?:
| typeof entityFilterFunctionDataRef.T
| typeof entityFilterExpressionDataRef.T;
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
},
{ node, config },
) {
@@ -92,13 +95,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({
yield coreExtensionData.routeRef(routeRef);
}
if (config.filter) {
yield entityFilterExpressionDataRef(config.filter);
} else if (typeof filter === 'string') {
yield entityFilterExpressionDataRef(filter);
} else if (typeof filter === 'function') {
yield entityFilterFunctionDataRef(filter);
}
yield* resolveEntityFilterData(filter, config, node);
if (group) {
yield entityContentGroupDataRef(group);
@@ -25,6 +25,10 @@ import {
EntityCardType,
} from './extensionData';
import React from 'react';
import { EntityPredicate } from '../predicates';
import { resolveEntityFilterData } from './resolveEntityFilterData';
import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema';
import { Entity } from '@backstage/catalog-model';
/** @alpha */
export interface EntityContentLayoutProps {
@@ -57,7 +61,8 @@ export const EntityContentLayoutBlueprint = createExtensionBlueprint({
config: {
schema: {
type: z => z.string().optional(),
filter: z => z.string().optional(),
filter: z =>
z.union([z.string(), createEntityPredicateSchema(z)]).optional(),
},
},
*factory(
@@ -65,22 +70,14 @@ export const EntityContentLayoutBlueprint = createExtensionBlueprint({
loader,
filter,
}: {
filter?:
| typeof entityFilterFunctionDataRef.T
| typeof entityFilterExpressionDataRef.T;
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
loader: () => Promise<
(props: EntityContentLayoutProps) => React.JSX.Element
>;
},
{ node, config },
) {
if (config.filter) {
yield entityFilterExpressionDataRef(config.filter);
} else if (typeof filter === 'string') {
yield entityFilterExpressionDataRef(filter);
} else if (typeof filter === 'function') {
yield entityFilterFunctionDataRef(filter);
}
yield* resolveEntityFilterData(filter, config, node);
yield entityCardLayoutComponentDataRef(
ExtensionBoundary.lazyComponent(node, loader),
@@ -0,0 +1,54 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
entityFilterExpressionDataRef,
entityFilterFunctionDataRef,
} from './extensionData';
import {
EntityPredicate,
entityPredicateToFilterFunction,
} from '../predicates';
import { Entity } from '@backstage/catalog-model';
import { AppNode } from '@backstage/frontend-plugin-api';
export function* resolveEntityFilterData(
filter: ((entity: Entity) => boolean) | EntityPredicate | string | undefined,
config: { filter?: EntityPredicate | string },
node: AppNode,
) {
if (typeof config.filter === 'string') {
// eslint-disable-next-line no-console
console.warn(
`DEPRECATION WARNING: Using a string-based filter in the configuration for '${node.spec.id}' is deprecated. Use an entity predicate object instead.`,
);
yield entityFilterExpressionDataRef(config.filter);
} else if (config.filter) {
yield entityFilterFunctionDataRef(
entityPredicateToFilterFunction(config.filter),
);
} else if (typeof filter === 'function') {
yield entityFilterFunctionDataRef(filter);
} else if (typeof filter === 'string') {
// eslint-disable-next-line no-console
console.warn(
`DEPRECATION WARNING: Using a string as the default filter for '${node.spec.id}' is deprecated. Use an entity predicate object instead.`,
);
yield entityFilterExpressionDataRef(filter);
} else if (filter) {
yield entityFilterFunctionDataRef(entityPredicateToFilterFunction(filter));
}
}
@@ -20,15 +20,15 @@ import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import React, { ComponentType } from 'react';
import { EntityCardBlueprint } from '../blueprints';
import kebabCase from 'lodash/kebabCase';
import { EntityPredicate } from '../predicates';
import { Entity } from '@backstage/catalog-model';
/** @alpha */
export function convertLegacyEntityCardExtension(
LegacyExtension: ComponentType<{}>,
overrides?: {
name?: string;
filter?:
| typeof EntityCardBlueprint.dataRefs.filterFunction.T
| typeof EntityCardBlueprint.dataRefs.filterExpression.T;
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
},
): ExtensionDefinition {
const element = <LegacyExtension />;
@@ -28,15 +28,15 @@ import kebabCase from 'lodash/kebabCase';
import startCase from 'lodash/startCase';
import React, { ComponentType } from 'react';
import { EntityContentBlueprint } from '../blueprints';
import { EntityPredicate } from '../predicates';
import { Entity } from '@backstage/catalog-model';
/** @alpha */
export function convertLegacyEntityContentExtension(
LegacyExtension: ComponentType<{}>,
overrides?: {
name?: string;
filter?:
| typeof EntityContentBlueprint.dataRefs.filterFunction.T
| typeof EntityContentBlueprint.dataRefs.filterExpression.T;
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
defaultPath?: string;
defaultTitle?: string;
},
+1
View File
@@ -16,6 +16,7 @@
export * from './blueprints';
export * from './converters';
export * from './predicates';
export { catalogReactTranslationRef } from '../translation';
export { isOwnerOf } from '../utils/isOwnerOf';
export { useEntityPermission } from '../hooks/useEntityPermission';
@@ -0,0 +1,99 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { createEntityPredicateSchema } from './createEntityPredicateSchema';
describe('createEntityPredicateSchema', () => {
const schema = createEntityPredicateSchema(z);
it.each([
'string',
'',
[],
1,
{ kind: 'component', 'spec.type': 'service' },
{ 'metadata.tags': { $in: ['java'] } },
{
$all: [
{ 'metadata.tags': { $contains: 'java' } },
{ 'metadata.tags': { $contains: 'spring' } },
],
},
{ 'metadata.tags': ['java', 'spring'] },
{ 'metadata.tags': { $in: ['go'] } },
{ 'metadata.tags.0': 'java' },
{ $not: { 'metadata.tags': { $in: ['java'] } } },
{
$any: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'group' }],
},
{
relations: {
$contains: { type: 'ownedBy', targetRef: 'group:default/g' },
},
},
{
metadata: { $contains: { name: 'a' } },
},
{ kind: 'component', 'spec.type': { $in: ['service', 'website'] } },
{
$any: [
{
$all: [
{
kind: 'component',
'spec.type': { $in: ['service', 'website'] },
},
],
},
{ $all: [{ kind: 'api', 'spec.type': 'grpc' }] },
],
},
{ kind: 'component', 'spec.type': { $in: ['service'] } },
{ 'spec.owner': { $exists: true } },
{ 'spec.owner': { $exists: false } },
{ 'spec.type': 'service' },
{ $not: { 'spec.type': 'service' } },
{
kind: 'component',
'metadata.annotations.github.com/repo': { $exists: true },
},
{ $all: [{ x: { $exists: true } }] },
{ $any: [{ x: { $exists: true } }] },
{ $not: { x: { $exists: true } } },
{ $not: { $all: [{ x: { $exists: true } }] } },
])('should accept valid predicate %j', predicate => {
expect(schema.parse(predicate)).toEqual(predicate);
});
it.each([
{ kind: { 1: 'foo' } },
{ kind: { foo: 'bar' } },
{ kind: { $unknown: 'foo' } },
{ kind: { $in: 'foo' } },
{ kind: { $in: [{ x: 'foo' }] } },
{ kind: { $in: [{ x: 'foo' }] } },
{ 'spec.type': null },
{ $all: [{ x: { $unknown: true } }] },
{ $any: [{ x: { $unknown: true } }] },
{ $not: { x: { $unknown: true } } },
{ $not: { $all: [{ x: { $unknown: true } }] } },
{ $unknown: 'foo' },
])('should reject invalid predicate %j', predicate => {
const result = schema.safeParse(predicate);
expect(result.success).toBe(false);
});
});
@@ -0,0 +1,50 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityPredicate, EntityPredicateValue } from '.';
import type { z as zImpl, ZodType } from 'zod';
/** @internal */
export function createEntityPredicateSchema(z: typeof zImpl) {
const primitiveSchema = z.union([z.string(), z.number(), z.boolean()]);
const comparableValueSchema = z.union([
primitiveSchema,
z.array(primitiveSchema),
]);
// eslint-disable-next-line prefer-const
let valuePredicateSchema: ZodType<EntityPredicateValue>;
const predicateSchema = z.lazy(() =>
z.union([
comparableValueSchema,
z.object({ $all: z.array(predicateSchema) }),
z.object({ $any: z.array(predicateSchema) }),
z.object({ $not: predicateSchema }),
z.record(z.string().regex(/^(?!\$).*$/), valuePredicateSchema),
]),
) as ZodType<EntityPredicate>;
valuePredicateSchema = z.union([
comparableValueSchema,
z.object({ $exists: z.boolean() }),
z.object({ $in: z.array(primitiveSchema) }),
z.object({ $contains: predicateSchema }),
]) as ZodType<EntityPredicateValue>;
return predicateSchema;
}
@@ -0,0 +1,243 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { entityPredicateToFilterFunction } from './entityPredicateToFilterFunction';
import { EntityPredicate } from './types';
describe('entityPredicateToFilterFunction', () => {
const entities = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 's',
namespace: 'default',
annotations: {
'backstage.io/managed-by-location': 'url:service',
'github.com/repo': 'service',
},
tags: ['java', 'spring'],
},
spec: {
type: 'service',
owner: 'g',
},
relations: [
{
type: 'ownedBy',
targetRef: 'group:default/g',
},
{
type: 'providesApi',
targetRef: 'api:default/a',
},
],
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'w',
namespace: 'default',
annotations: {
'backstage.io/managed-by-location': 'url:website',
'github.com/repo': 'website',
},
},
spec: {
type: 'website',
owner: 'g',
},
relations: [
{
type: 'ownedBy',
targetRef: 'group:default/g',
},
{
type: 'dependsOn',
targetRef: 'api:default/a',
},
],
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'g',
namespace: 'default',
},
spec: {
type: 'squad',
},
relations: [
{
type: 'ownerOf',
targetRef: 'component:default/s',
},
{
type: 'ownerOf',
targetRef: 'component:default/w',
},
{
type: 'ownerOf',
targetRef: 'component:default/a',
},
],
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'a',
namespace: 'default',
},
spec: {
type: 'grpc',
owner: 'g',
definition: 'mock',
nothing: null,
oneNum: 1,
oneStr: '1',
},
relations: [
{
type: 'ownedBy',
targetRef: 'group:default/g',
},
{
type: 'apiProvidedBy',
targetRef: 'component:default/c',
},
{
type: 'dependencyOf',
targetRef: 'component:default/w',
},
],
},
];
it.each([
['s', { kind: 'component', 'spec.type': 'service' }],
['s', { 'metadata.tags': { $contains: 'java' } }],
[
's',
{
$all: [
{ 'metadata.tags': { $contains: 'java' } },
{ 'metadata.tags': { $contains: 'spring' } },
],
},
],
['s', { 'metadata.tags': ['java', 'spring'] }],
['', { 1: 'foo' }],
['s,w,g,a', {}],
['', { kind: { $unknown: 'foo' } }],
['', { '': 'component' }],
['s,w,g,a', Object.create({ kind: 'component' })],
['', { 'metadata.tags': { $contains: 'go' } }],
['', { 'metadata.tags.0': 'java' }],
['w,g,a', { $not: { 'metadata.tags': { $contains: 'java' } } }],
[
's,g',
{
$any: [
{ kind: 'component', 'spec.type': 'service' },
{ kind: 'group' },
],
},
],
[
'w,a',
{
$not: {
$any: [
{ kind: 'component', 'spec.type': 'service' },
{ kind: 'group' },
],
},
},
],
[
's,w,a',
{
relations: {
$contains: { type: 'ownedBy', targetRef: 'group:default/g' },
},
},
],
[
'',
{
metadata: { $contains: { name: 'a' } },
},
],
['', { $unknown: 'ignored' } as unknown as EntityPredicate],
[
's,w',
{ kind: 'component', 'spec.type': { $in: ['service', 'website'] } },
],
[
's,w,a',
{
$any: [
{
$all: [
{
kind: 'component',
'spec.type': { $in: ['service', 'website'] },
},
],
},
{ $all: [{ kind: 'api', 'spec.type': 'grpc' }] },
],
},
],
['s', { kind: 'component', 'spec.type': { $in: ['service'] } }],
[
'w',
{
$all: [
{ kind: 'component' },
{ $not: { 'spec.type': { $in: ['service'] } } },
],
},
],
['s,w,a', { 'spec.owner': { $exists: true } }],
['g', { 'spec.owner': { $exists: false } }],
['s', { 'spec.type': 'service' }],
['', { 'spec.nothing': null }],
['w,g,a', { $not: { 'spec.type': 'service' } }],
['', { 'spec.type': null }],
['a', { 'spec.oneNum': 1 }],
['a', { 'spec.oneStr': 1 }],
['a', { 'spec.oneNum': '1' }],
['a', { 'spec.oneStr': '1' }],
[
's,w',
{
kind: 'component',
'metadata.annotations.github.com/repo': { $exists: true },
},
],
])('filter entry %#', (expected, filter) => {
const filtered = entities.filter(entity =>
entityPredicateToFilterFunction(filter)(entity),
);
expect(filtered.map(e => e.metadata.name).sort()).toEqual(
expected.split(',').filter(Boolean).sort(),
);
});
});
@@ -0,0 +1,127 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JsonValue } from '@backstage/types';
import {
EntityPredicate,
EntityPredicatePrimitive,
EntityPredicateValue,
} from './types';
import { valueAtPath } from './valueAtPath';
/**
* Convert an entity predicate to a filter function that can be used to filter entities.
* @alpha
*/
export function entityPredicateToFilterFunction<T extends JsonValue>(
entityPredicate: EntityPredicate,
): (value: T) => boolean {
return value => evaluateEntityPredicate(entityPredicate, value);
}
/**
* Evaluate a entity predicate against a value, typically an entity.
*
* @internal
*/
function evaluateEntityPredicate(
filter: EntityPredicate,
value: JsonValue,
): boolean {
if (typeof filter !== 'object' || filter === null || Array.isArray(filter)) {
return valuesAreEqual(value, filter);
}
if ('$all' in filter) {
return filter.$all.every(f => evaluateEntityPredicate(f, value));
}
if ('$any' in filter) {
return filter.$any.some(f => evaluateEntityPredicate(f, value));
}
if ('$not' in filter) {
return !evaluateEntityPredicate(filter.$not, value);
}
for (const filterKey in filter) {
if (!Object.hasOwn(filter, filterKey)) {
continue;
}
if (filterKey.startsWith('$')) {
return false;
}
if (
!evaluatePredicateValue(filter[filterKey], valueAtPath(value, filterKey))
) {
return false;
}
}
return true;
}
/**
* Evaluate a single value against a predicate value.
*
* @internal
*/
function evaluatePredicateValue(
filter: EntityPredicateValue,
value: JsonValue | undefined,
): boolean {
if (typeof filter !== 'object' || filter === null || Array.isArray(filter)) {
return valuesAreEqual(value, filter);
}
if ('$contains' in filter) {
if (!Array.isArray(value)) {
return false;
}
return value.some(v => evaluateEntityPredicate(filter.$contains, v));
}
if ('$in' in filter) {
return filter.$in.includes(value as EntityPredicatePrimitive);
}
if ('$exists' in filter) {
if (filter.$exists === true) {
return value !== undefined;
}
return value === undefined;
}
return false;
}
function valuesAreEqual(
a: JsonValue | undefined,
b: JsonValue | undefined,
): boolean {
if (a === null || b === null) {
return false;
}
if (a === b) {
return true;
}
if (typeof a === 'string' && typeof b === 'string') {
return a.toLocaleUpperCase('en-US') === b.toLocaleUpperCase('en-US');
}
if (typeof a === 'number' || typeof b === 'number') {
return String(a) === String(b);
}
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((v, i) => valuesAreEqual(v, b[i]));
}
return false;
}
@@ -0,0 +1,23 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type {
EntityPredicate,
EntityPredicateExpression,
EntityPredicatePrimitive,
EntityPredicateValue,
} from './types';
export { entityPredicateToFilterFunction } from './entityPredicateToFilterFunction';
@@ -0,0 +1,40 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @alpha */
export type EntityPredicate =
| EntityPredicateExpression
| EntityPredicatePrimitive
| { $all: EntityPredicate[] }
| { $any: EntityPredicate[] }
| { $not: EntityPredicate };
/** @alpha */
export type EntityPredicateExpression = {
[KPath in string]: EntityPredicateValue;
} & {
[KPath in `$${string}`]: never;
};
/** @alpha */
export type EntityPredicateValue =
| EntityPredicatePrimitive
| { $exists: boolean }
| { $in: EntityPredicatePrimitive[] }
| { $contains: EntityPredicateExpression };
/** @alpha */
export type EntityPredicatePrimitive = string | number | boolean;
@@ -0,0 +1,63 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { valueAtPath } from './valueAtPath';
describe('valueAtPath', () => {
const subject = {
name: 'Test',
fields: {
value: 123,
tags: ['production', 'beta'],
array: [1, 2, { nested: 'value' }],
nested: {
level: 1,
deeper: {
level: 2,
},
},
},
mixed: {
'foo.bar.baz': 1,
foo: { 'bar.baz': 3, bar: { baz: 4, qux: 4 } },
'foo.bar': { baz: 2, qux: 2, quux: 2 },
annotations: {
'example.com/description': 'A test subject',
'long.domain.example.com/custom': 'long',
},
},
};
it.each([
['name', 'Test'],
['unknown', undefined],
['fields.value', 123],
['fields.tags', ['production', 'beta']],
['fields.array', [1, 2, { nested: 'value' }]],
['fields.array.0', undefined], // Arrays are not traversed
['fields.array.2', undefined], // Arrays are not traversed
['fields.array.2.nested', undefined], // Arrays are not traversed
['fields.nested.level', 1],
['fields.nested.deeper.level', 2],
['mixed.foo.bar.baz', 1], // First one wins
['mixed.foo.bar.qux', 4], // First one wins
['mixed.foo.bar.quux', 2], // Should not get stuck in earlier partial matches
['mixed.annotations.example.com/description', 'A test subject'],
['mixed.annotations.long.domain.example.com/custom', 'long'],
])(`should find value at path %s`, (path, expected) => {
expect(valueAtPath(subject, path)).toEqual(expected);
});
});
@@ -0,0 +1,69 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JsonValue } from '@backstage/types';
/**
* Looks up a value by path in a nested object structure.
*
* @remarks
*
* The path should be a dot-separated string of keys to traverse. The traversal
* will tolerate object keys containing dots, and will keep searching until a
* value has been found or all matching keys have been traversed.
*
* This lookup does not traverse into arrays, returning `undefined` instead.
*
* @internal
*/
export function valueAtPath(
value: JsonValue | undefined,
path: string,
): JsonValue | undefined {
if (!path) {
return undefined;
}
if (
value === undefined ||
value === null ||
typeof value !== 'object' ||
Array.isArray(value)
) {
return undefined;
}
for (const valueKey in value) {
if (!Object.hasOwn(value, valueKey)) {
continue;
}
if (valueKey === path) {
if (value[valueKey] !== undefined) {
return value[valueKey];
}
}
if (path.startsWith(`${valueKey}.`)) {
const found = valueAtPath(
value[valueKey],
path.slice(valueKey.length + 1),
);
if (found !== undefined) {
return found;
}
}
}
return undefined;
}
+34 -33
View File
@@ -12,6 +12,7 @@ import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { EntityCardType } from '@backstage/plugin-catalog-react/alpha';
import { EntityContentLayoutProps } from '@backstage/plugin-catalog-react/alpha';
import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha';
import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
@@ -353,11 +354,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'about';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -386,7 +387,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -394,11 +395,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'depends-on-components';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -427,7 +428,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -435,11 +436,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'depends-on-resources';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -468,7 +469,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -476,11 +477,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'has-components';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -509,7 +510,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -517,11 +518,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'has-resources';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -550,7 +551,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -558,11 +559,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'has-subcomponents';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -591,7 +592,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -599,11 +600,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'has-subdomains';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -632,7 +633,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -640,11 +641,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'has-systems';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -673,7 +674,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -681,11 +682,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'labels';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -714,7 +715,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -722,11 +723,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'links';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -755,7 +756,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -763,11 +764,11 @@ const _default: FrontendPlugin<
config: {
path: string | undefined;
title: string | undefined;
filter: string | undefined;
filter: EntityPredicate | undefined;
group: string | false | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
title?: string | undefined;
path?: string | undefined;
group?: string | false | undefined;
@@ -877,7 +878,7 @@ const _default: FrontendPlugin<
| 'observability'
| undefined;
routeRef?: RouteRef<AnyRouteRefParams> | undefined;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
};
}>;
'nav-item:catalog': ExtensionDefinition<{
+4 -3
View File
@@ -9,6 +9,7 @@ import { AnyApiFactory } from '@backstage/frontend-plugin-api';
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
@@ -88,11 +89,11 @@ const _default: FrontendPlugin<
config: {
path: string | undefined;
title: string | undefined;
filter: string | undefined;
filter: EntityPredicate | undefined;
group: string | false | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
title?: string | undefined;
path?: string | undefined;
group?: string | false | undefined;
@@ -146,7 +147,7 @@ const _default: FrontendPlugin<
| 'observability'
| undefined;
routeRef?: RouteRef<AnyRouteRefParams> | undefined;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
};
}>;
'page:kubernetes': ExtensionDefinition<{
+13 -12
View File
@@ -6,6 +6,7 @@
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { EntityCardType } from '@backstage/plugin-catalog-react/alpha';
import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -22,11 +23,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'group-profile';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -59,7 +60,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -67,11 +68,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'members-list';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -104,7 +105,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -112,11 +113,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'ownership';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -149,7 +150,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
@@ -157,11 +158,11 @@ const _default: FrontendPlugin<
kind: 'entity-card';
name: 'user-profile';
config: {
filter: string | undefined;
filter: EntityPredicate | undefined;
type: 'full' | 'info' | 'peek' | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
type?: 'full' | 'info' | 'peek' | undefined;
};
output:
@@ -194,7 +195,7 @@ const _default: FrontendPlugin<
inputs: {};
params: {
loader: () => Promise<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
type?: EntityCardType | undefined;
};
}>;
+4 -3
View File
@@ -8,6 +8,7 @@ import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api';
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -89,11 +90,11 @@ const _default: FrontendPlugin<
config: {
path: string | undefined;
title: string | undefined;
filter: string | undefined;
filter: EntityPredicate | undefined;
group: string | false | undefined;
};
configInput: {
filter?: string | undefined;
filter?: EntityPredicate | undefined;
title?: string | undefined;
path?: string | undefined;
group?: string | false | undefined;
@@ -178,7 +179,7 @@ const _default: FrontendPlugin<
| 'observability'
| undefined;
routeRef?: RouteRef<AnyRouteRefParams> | undefined;
filter?: string | ((entity: Entity) => boolean) | undefined;
filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined;
};
}>;
'nav-item:techdocs': ExtensionDefinition<{
+1
View File
@@ -6345,6 +6345,7 @@ __metadata:
react-use: ^17.2.4
yaml: ^2.0.0
zen-observable: ^0.10.0
zod: ^3.22.4
peerDependencies:
"@types/react": ^17.0.0 || ^18.0.0
react: ^17.0.0 || ^18.0.0