diff --git a/.changeset/fair-pianos-add.md b/.changeset/fair-pianos-add.md new file mode 100644 index 0000000000..fda4112c57 --- /dev/null +++ b/.changeset/fair-pianos-add.md @@ -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). diff --git a/.changeset/many-wolves-own.md b/.changeset/many-wolves-own.md new file mode 100644 index 0000000000..672af86d62 --- /dev/null +++ b/.changeset/many-wolves-own.md @@ -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. diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md index 5c786a6f48..38c4b2a951 100644 --- a/docs/features/software-catalog/catalog-customization.md +++ b/docs/features/software-catalog/catalog-customization.md @@ -502,3 +502,201 @@ const routes = ( ); ``` + +## 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: [ { }, { }, ...] } +``` + +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: [ { }, { }, ...] } +``` + +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: { } } +``` + +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: } } +``` + +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: [ , , ... ] } } +``` + +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: { } } } +``` + +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] +``` diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index fdda5958de..8129ab87f7 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -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: diff --git a/packages/config-loader/src/sources/transform/include.ts b/packages/config-loader/src/sources/transform/include.ts index fb2b22ef46..c476b0434f 100644 --- a/packages/config-loader/src/sources/transform/include.ts +++ b/packages/config-loader/src/sources/transform/include.ts @@ -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; @@ -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( diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md index 78e8b9442b..73c1fc3ae9 100644 --- a/plugins/api-docs/report-alpha.api.md +++ b/plugins/api-docs/report-alpha.api.md @@ -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; - 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; - 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; - 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; - 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; - 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; - 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 | 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 | undefined; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; }; }>; 'nav-item:api-docs': ExtensionDefinition<{ diff --git a/plugins/catalog-graph/report-alpha.api.md b/plugins/catalog-graph/report-alpha.api.md index 9cdd5c3f01..fcc9265cb4 100644 --- a/plugins/catalog-graph/report-alpha.api.md +++ b/plugins/catalog-graph/report-alpha.api.md @@ -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; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 8a3166961d..33893deefc 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -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", diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 02fac5f75a..f495a043cc 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -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; - 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 | undefined; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; }; output: | ConfigurableExtensionDataRef @@ -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( + 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; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx index 563bb15ebd..f4e518799e 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx @@ -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": [ diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts index 5ef6106f40..b2f64c19e6 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts @@ -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; - 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) { diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx index 15d0e3e1ab..9c118df26c 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx @@ -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": [ diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts index aa3608c299..dbfb11092a 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts @@ -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); diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx index ab15a3d791..033b0f2187 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx @@ -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), diff --git a/plugins/catalog-react/src/alpha/blueprints/resolveEntityFilterData.ts b/plugins/catalog-react/src/alpha/blueprints/resolveEntityFilterData.ts new file mode 100644 index 0000000000..75b7784c18 --- /dev/null +++ b/plugins/catalog-react/src/alpha/blueprints/resolveEntityFilterData.ts @@ -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)); + } +} diff --git a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.tsx b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.tsx index ed92461736..3c76e6b8d6 100644 --- a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.tsx +++ b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.tsx @@ -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 = ; diff --git a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx index 02f5d104bc..2f106a20e1 100644 --- a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx +++ b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx @@ -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; }, diff --git a/plugins/catalog-react/src/alpha/index.ts b/plugins/catalog-react/src/alpha/index.ts index a46fec0615..4ff4dbf0dd 100644 --- a/plugins/catalog-react/src/alpha/index.ts +++ b/plugins/catalog-react/src/alpha/index.ts @@ -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'; diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts new file mode 100644 index 0000000000..4fdf921c1a --- /dev/null +++ b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts @@ -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); + }); +}); diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts new file mode 100644 index 0000000000..9e5f44b327 --- /dev/null +++ b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts @@ -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; + + 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; + + valuePredicateSchema = z.union([ + comparableValueSchema, + z.object({ $exists: z.boolean() }), + z.object({ $in: z.array(primitiveSchema) }), + z.object({ $contains: predicateSchema }), + ]) as ZodType; + + return predicateSchema; +} diff --git a/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.test.ts b/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.test.ts new file mode 100644 index 0000000000..c83b213970 --- /dev/null +++ b/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.test.ts @@ -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(), + ); + }); +}); diff --git a/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.ts b/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.ts new file mode 100644 index 0000000000..2fa220e9e2 --- /dev/null +++ b/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.ts @@ -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( + 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; +} diff --git a/plugins/catalog-react/src/alpha/predicates/index.ts b/plugins/catalog-react/src/alpha/predicates/index.ts new file mode 100644 index 0000000000..71be7403de --- /dev/null +++ b/plugins/catalog-react/src/alpha/predicates/index.ts @@ -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'; diff --git a/plugins/catalog-react/src/alpha/predicates/types.ts b/plugins/catalog-react/src/alpha/predicates/types.ts new file mode 100644 index 0000000000..a4a2e9199f --- /dev/null +++ b/plugins/catalog-react/src/alpha/predicates/types.ts @@ -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; diff --git a/plugins/catalog-react/src/alpha/predicates/valueAtPath.test.ts b/plugins/catalog-react/src/alpha/predicates/valueAtPath.test.ts new file mode 100644 index 0000000000..7ea563e4a1 --- /dev/null +++ b/plugins/catalog-react/src/alpha/predicates/valueAtPath.test.ts @@ -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); + }); +}); diff --git a/plugins/catalog-react/src/alpha/predicates/valueAtPath.ts b/plugins/catalog-react/src/alpha/predicates/valueAtPath.ts new file mode 100644 index 0000000000..acb46995d8 --- /dev/null +++ b/plugins/catalog-react/src/alpha/predicates/valueAtPath.ts @@ -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; +} diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index 4961e2e9fb..3732f3355a 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -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; - 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; - 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; - 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; - 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; - 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; - 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; - 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; - 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; - 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; - 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 | undefined; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; }; }>; 'nav-item:catalog': ExtensionDefinition<{ diff --git a/plugins/kubernetes/report-alpha.api.md b/plugins/kubernetes/report-alpha.api.md index ef824bc435..0cca232bf6 100644 --- a/plugins/kubernetes/report-alpha.api.md +++ b/plugins/kubernetes/report-alpha.api.md @@ -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 | undefined; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; }; }>; 'page:kubernetes': ExtensionDefinition<{ diff --git a/plugins/org/report-alpha.api.md b/plugins/org/report-alpha.api.md index eb5c41faa1..508c0ed04e 100644 --- a/plugins/org/report-alpha.api.md +++ b/plugins/org/report-alpha.api.md @@ -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; - 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; - 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; - 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; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index 681961a33e..0dcca55c73 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -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 | undefined; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; }; }>; 'nav-item:techdocs': ExtensionDefinition<{ diff --git a/yarn.lock b/yarn.lock index 1a5ac96f70..e5260d2a63 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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