From 57c1b4752fa9b3302600c1b505e945a15b2e1cb0 Mon Sep 17 00:00:00 2001 From: Juan Pablo Garcia Ripa Date: Wed, 15 Mar 2023 22:49:27 +0100 Subject: [PATCH] add support to "exist_key" filters Signed-off-by: Juan Pablo Garcia Ripa --- .changeset/fair-roses-stare.md | 14 +++ .../software-templates/writing-templates.md | 14 +++ plugins/scaffolder/api-report.md | 72 +++++++++++++-- .../fields/EntityPicker/EntityPicker.test.tsx | 66 +++++++++++--- .../fields/EntityPicker/EntityPicker.tsx | 87 +++++++++++++++++-- .../components/fields/EntityPicker/schema.ts | 12 ++- 6 files changed, 238 insertions(+), 27 deletions(-) create mode 100644 .changeset/fair-roses-stare.md diff --git a/.changeset/fair-roses-stare.md b/.changeset/fair-roses-stare.md new file mode 100644 index 0000000000..358765e092 --- /dev/null +++ b/.changeset/fair-roses-stare.md @@ -0,0 +1,14 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Allow use of `{ exists: true }` value inside filters to filter entities that has that key. + +this example will filter all entities that has the annotation `someAnnotation` set to any value. + +```yaml +ui:options: + catalogFilter: + kind: Group + metadata.annotations.someAnnotation: { exists: true } +``` diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index a272f05f3f..15e6a833cc 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -472,6 +472,20 @@ owner: kind: [Group, User] ``` +#### `catalogFilter` + +The `catalogFilter` allow you to filter the list entities using any of the [catalog api filters](https://backstage.io/docs/features/software-catalog/software-catalog-api#filtering): + +For example, if you want to show users in the `default` namespace, and groups with the `github.com/team-slug` annotation, you can do the following: + +```yaml +catalogFilter: + - kind: [User] + metadata.namespace: default + - kind: [Group] + metadata.annotations.github.com/team-slug: { exists: true } +``` + ## `spec.steps` - `Action[]` The `steps` is an array of the things that you want to happen part of this diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 6b8d6d345f..d03b27c810 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -80,8 +80,22 @@ export const EntityPickerFieldExtension: FieldExtensionComponent_2< allowedKinds?: string[] | undefined; allowArbitraryValues?: boolean | undefined; catalogFilter?: - | Record - | Record[] + | Record< + string, + | string + | string[] + | { + exists?: boolean | undefined; + } + > + | Record< + string, + | string + | string[] + | { + exists?: boolean | undefined; + } + >[] | undefined; } >; @@ -95,8 +109,22 @@ export const EntityPickerFieldSchema: FieldSchema< allowedKinds?: string[] | undefined; allowArbitraryValues?: boolean | undefined; catalogFilter?: - | Record - | Record[] + | Record< + string, + | string + | string[] + | { + exists?: boolean | undefined; + } + > + | Record< + string, + | string + | string[] + | { + exists?: boolean | undefined; + } + >[] | undefined; } >; @@ -212,8 +240,22 @@ export const OwnerPickerFieldExtension: FieldExtensionComponent_2< allowedKinds?: string[] | undefined; allowArbitraryValues?: boolean | undefined; catalogFilter?: - | Record - | Record[] + | Record< + string, + | string + | string[] + | { + exists?: boolean | undefined; + } + > + | Record< + string, + | string + | string[] + | { + exists?: boolean | undefined; + } + >[] | undefined; } >; @@ -226,8 +268,22 @@ export const OwnerPickerFieldSchema: FieldSchema< allowedKinds?: string[] | undefined; allowArbitraryValues?: boolean | undefined; catalogFilter?: - | Record - | Record[] + | Record< + string, + | string + | string[] + | { + exists?: boolean | undefined; + } + > + | Record< + string, + | string + | string[] + | { + exists?: boolean | undefined; + } + >[] | undefined; } >; diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx index 3cc45e6412..937ff7d07a 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { EntityFilterQuery } from '@backstage/catalog-client'; +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; @@ -22,6 +22,7 @@ import { FieldProps } from '@rjsf/core'; import { fireEvent } from '@testing-library/react'; import React from 'react'; import { EntityPicker } from './EntityPicker'; +import { EntityPickerProps } from './schema'; const makeEntity = (kind: string, namespace: string, name: string): Entity => ({ apiVersion: 'backstage.io/v1beta1', @@ -34,15 +35,7 @@ describe('', () => { const onChange = jest.fn(); const schema = {}; const required = false; - let uiSchema: { - 'ui:options': { - allowedKinds?: string[]; - defaultKind?: string; - allowArbitraryValues?: boolean; - defaultNamespace?: string | false; - catalogFilter?: EntityFilterQuery; - }; - }; + let uiSchema: EntityPickerProps['uiSchema']; const rawErrors: string[] = []; const formData = undefined; @@ -192,6 +185,59 @@ describe('', () => { ], }); }); + it('allow single top level filter', async () => { + uiSchema = { + 'ui:options': { + catalogFilter: { + kind: ['Group'], + 'metadata.name': 'test-entity', + }, + }, + }; + + catalogApi.getEntities.mockResolvedValue({ items: entities }); + + await renderInTestApp( + + + , + ); + + expect(catalogApi.getEntities).toHaveBeenCalledWith({ + filter: { + kind: ['Group'], + 'metadata.name': 'test-entity', + }, + }); + }); + + it('search for entitities containing an specific key', async () => { + const uiSchemaWithBoolean = { + 'ui:options': { + catalogFilter: [ + { + kind: ['User'], + 'metadata.annotation.some/anotation': { exists: true }, + }, + ], + }, + }; + + await renderInTestApp( + + + , + ); + + expect(catalogApi.getEntities).toHaveBeenCalledWith({ + filter: [ + { + kind: ['User'], + 'metadata.annotation.some/anotation': CATALOG_FILTER_EXISTS, + }, + ], + }); + }); }); describe('catalogFilter should take precedence over allowedKinds', () => { diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx index 48d6453ab9..aeb4a52f77 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx @@ -13,7 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { type EntityFilterQuery } from '@backstage/catalog-client'; +import { + type EntityFilterQuery, + CATALOG_FILTER_EXISTS, +} from '@backstage/catalog-client'; import { Entity, parseEntityRef, @@ -31,7 +34,12 @@ import Autocomplete, { } from '@material-ui/lab/Autocomplete'; import React, { useCallback, useEffect } from 'react'; import useAsync from 'react-use/lib/useAsync'; -import { EntityPickerProps } from './schema'; +import { + EntityPickerFilterQueryValue, + EntityPickerProps, + EntityPickerUiOptions, + EntityPickerFilterQuery, +} from './schema'; export { EntityPickerSchema } from './schema'; @@ -51,12 +59,7 @@ export const EntityPicker = (props: EntityPickerProps) => { formData, idSchema, } = props; - const allowedKinds = uiSchema['ui:options']?.allowedKinds; - - const catalogFilter: EntityFilterQuery | undefined = - uiSchema['ui:options']?.catalogFilter || - (allowedKinds && { kind: allowedKinds }); - + const catalogFilter = buildCatalogFilter(uiSchema); const defaultKind = uiSchema['ui:options']?.defaultKind; const defaultNamespace = uiSchema['ui:options']?.defaultNamespace || undefined; @@ -167,3 +170,71 @@ export const EntityPicker = (props: EntityPickerProps) => { ); }; + +/** + * Converts a especial `{exists: true}` value to the `CATALOG_FILTER_EXISTS` symbol. + * + * @param value - The value to convert. + * @returns The converted value. + */ +function convertOpsValues( + value: Exclude>, +): string | symbol { + if (typeof value === 'object' && value.exists) { + return CATALOG_FILTER_EXISTS; + } + return value?.toString(); +} + +/** + * Converts schema filters to entity filter query, replacing `{exists:true}` values + * with the constant `CATALOG_FILTER_EXISTS`. + * + * @param schemaFilters - An object containing schema filters with keys as filter names + * and values as filter values. + * @returns An object with the same keys as the input object, but with `{exists:true}` values + * transformed to `CATALOG_FILTER_EXISTS` symbol. + */ +function convertSchemaFiltersToQuery( + schemaFilters: EntityPickerFilterQuery, +): Exclude> { + const query: EntityFilterQuery = {}; + + for (const [key, value] of Object.entries(schemaFilters)) { + if (Array.isArray(value)) { + query[key] = value; + } else { + query[key] = convertOpsValues(value); + } + } + + return query; +} + +/** + * Builds an `EntityFilterQuery` based on the `uiSchema` passed in. + * If `catalogFilter` is specified in the `uiSchema`, it is converted to a `EntityFilterQuery`. + * If `allowedKinds` is specified in the `uiSchema` will support the legacy `allowedKinds` option. + * + * @param uiSchema The `uiSchema` of an `EntityPicker` component. + * @returns An `EntityFilterQuery` based on the `uiSchema`, or `undefined` if `catalogFilter` is not specified in the `uiSchema`. + */ +function buildCatalogFilter( + uiSchema: EntityPickerProps['uiSchema'], +): EntityFilterQuery | undefined { + const allowedKinds = uiSchema['ui:options']?.allowedKinds; + + const catalogFilter: EntityPickerUiOptions['catalogFilter'] | undefined = + uiSchema['ui:options']?.catalogFilter || + (allowedKinds && { kind: allowedKinds }); + + if (!catalogFilter) { + return undefined; + } + + if (Array.isArray(catalogFilter)) { + return catalogFilter.map(convertSchemaFiltersToQuery); + } + + return convertSchemaFiltersToQuery(catalogFilter); +} diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/schema.ts b/plugins/scaffolder/src/components/fields/EntityPicker/schema.ts index f83dfbf817..eb1bba364b 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/schema.ts +++ b/plugins/scaffolder/src/components/fields/EntityPicker/schema.ts @@ -20,7 +20,10 @@ import { makeFieldSchemaFromZod } from '../utils'; * @public */ export const entityQueryFilterExpressionSchema = z.record( - z.string().or(z.array(z.string())), + z + .string() + .or(z.object({ exists: z.boolean().optional() })) + .or(z.array(z.string())), ); /** @@ -74,3 +77,10 @@ export type EntityPickerUiOptions = export type EntityPickerProps = typeof EntityPickerFieldSchema.type; export const EntityPickerSchema = EntityPickerFieldSchema.schema; + +export type EntityPickerFilterQuery = z.TypeOf< + typeof entityQueryFilterExpressionSchema +>; + +export type EntityPickerFilterQueryValue = + EntityPickerFilterQuery[keyof EntityPickerFilterQuery];