From a86920b28782572e930b2b1b0106065022296b26 Mon Sep 17 00:00:00 2001 From: Marc Rooding Date: Wed, 6 Sep 2023 10:38:37 +0200 Subject: [PATCH 01/12] feat: introduced a new MultiEntityPicker field that supports selecting multiple Entities Signed-off-by: Marc Rooding --- .changeset/metal-elephants-sit.md | 5 + .../software-templates/ui-options-examples.md | 111 ++++++++ .../MultiEntityPicker/MultiEntityPicker.tsx | 261 ++++++++++++++++++ .../fields/MultiEntityPicker/index.ts | 22 ++ .../fields/MultiEntityPicker/schema.ts | 77 ++++++ plugins/scaffolder/src/plugin.tsx | 19 ++ 6 files changed, 495 insertions(+) create mode 100644 .changeset/metal-elephants-sit.md create mode 100644 plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx create mode 100644 plugins/scaffolder/src/components/fields/MultiEntityPicker/index.ts create mode 100644 plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts diff --git a/.changeset/metal-elephants-sit.md b/.changeset/metal-elephants-sit.md new file mode 100644 index 0000000000..17182ff1ea --- /dev/null +++ b/.changeset/metal-elephants-sit.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Introduced a new MultiEntityPicker field that supports selecting multiple Entities diff --git a/docs/features/software-templates/ui-options-examples.md b/docs/features/software-templates/ui-options-examples.md index b402f68aee..7f0e5b225f 100644 --- a/docs/features/software-templates/ui-options-examples.md +++ b/docs/features/software-templates/ui-options-examples.md @@ -119,6 +119,117 @@ entity: defaultNamespace: payment ``` +## MultiEntityPicker + +The input props that can be specified under `ui:options` for the `MultiEntityPicker` field extension. + +### `allowArbitraryValues` + +Whether to allow arbitrary user input. Defaults to true. + +`allowArbitraryValues` provides input validation when selecting an entity as the values you enter will correspond to a valid entity. + +- Adding a valid entity with `allowArbitraryValues` as `false` + +```yaml +entity: + title: Entities + type: array + description: Entities of the component + ui:field: MultiEntityPicker + ui:options: + allowArbitraryValues: false +``` + +- Adding an arbitrary entity with `allowArbitraryValues` as `true` (default value) + +```yaml +entity: + title: Entities + type: array + description: Entities of the component + ui:field: MultiEntityPicker + ui:options: + allowArbitraryValues: true +``` + +### `catalogFilter` + +`catalogFilter` supports filtering options by any field(s) of an entity. + +- Get all entities of kind `Group` + +```yaml +entity: + title: Entities + type: array + description: Entities of the component + ui:field: MultiEntityPicker + ui:options: + catalogFilter: + - kind: Group +``` + +- Get entities of kind `Group` and spec.type `team` + +```yaml +entity: + title: Entities + type: array + description: Entities of the component + ui:field: MultiEntityPicker + ui:options: + catalogFilter: + - kind: Group + spec.type: team +``` + +For the full details on the spec.\* values see [here](../software-catalog/descriptor-format.md#kind-group). + +### `defaultKind` + +The default entity kind. + +```yaml +system: + title: System + type: array + description: Systems of the component + ui:field: MultiEntityPicker + ui:options: + catalogFilter: + kind: System + defaultKind: System +``` + +### `defaultNamespace` + +The ID of a namespace that the entity belongs to. The default value is `default`. + +- Listing all entities in the `default` namespace (default value) + +```yaml +entity: + title: Entity + type: array + description: Entities of the component + ui:field: MultiEntityPicker + ui:options: + defaultNamespace: default +``` + +- Listing all entities in the `payment` namespace + +```yaml +entity: + title: Entity + type: array + description: Entities of the component + ui:field: MultiEntityPicker + ui:options: + defaultNamespace: payment +``` + ## `OwnerPicker` The input props that can be specified under `ui:options` for the `OwnerPicker` field extension. diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx new file mode 100644 index 0000000000..0f27acca9a --- /dev/null +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx @@ -0,0 +1,261 @@ +/* + * Copyright 2021 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 { + type EntityFilterQuery, + CATALOG_FILTER_EXISTS, +} from '@backstage/catalog-client'; +import { + Entity, + parseEntityRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import { + catalogApiRef, + humanizeEntityRef, +} from '@backstage/plugin-catalog-react'; +import { TextField } from '@material-ui/core'; +import FormControl from '@material-ui/core/FormControl'; +import Autocomplete, { + AutocompleteChangeReason, +} from '@material-ui/lab/Autocomplete'; +import React, { useCallback, useEffect } from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import { FieldValidation } from '@rjsf/core'; +import { + MultiEntityPickerFilterQueryValue, + MultiEntityPickerProps, + MultiEntityPickerUiOptions, + MultiEntityPickerFilterQuery, +} from './schema'; + +export { MultiEntityPickerSchema } from './schema'; + +/** + * The underlying component that is rendered in the form for the `MultiEntityPicker` + * field extension. + * + * @public + */ +export const MultiEntityPicker = (props: MultiEntityPickerProps) => { + const { + onChange, + schema: { title = 'Entity', description = 'An entity from the catalog' }, + required, + uiSchema, + rawErrors, + formData, + idSchema, + } = props; + const catalogFilter = buildCatalogFilter(uiSchema); + const defaultKind = uiSchema['ui:options']?.defaultKind; + const defaultNamespace = + uiSchema['ui:options']?.defaultNamespace || undefined; + + const catalogApi = useApi(catalogApiRef); + + const { value: entities, loading } = useAsync(async () => { + const { items } = await catalogApi.getEntities( + catalogFilter ? { filter: catalogFilter } : undefined, + ); + return items; + }); + const allowArbitraryValues = + uiSchema['ui:options']?.allowArbitraryValues ?? true; + + const getLabel = useCallback( + (ref: string) => { + try { + return humanizeEntityRef( + parseEntityRef(ref, { defaultKind, defaultNamespace }), + { + defaultKind, + defaultNamespace, + }, + ); + } catch (err) { + return ref; + } + }, + [defaultKind, defaultNamespace], + ); + + const onSelect = useCallback( + (_: any, refs: (string | Entity)[], reason: AutocompleteChangeReason) => { + const values = refs + .map(ref => { + if (typeof ref !== 'string') { + // if ref does not exist: pass 'undefined' to trigger validation for required value + return ref ? stringifyEntityRef(ref as Entity) : undefined; + } + if (reason === 'blur' || reason === 'create-option') { + // Add in default namespace, etc. + let entityRef = ref; + try { + // Attempt to parse the entity ref into it's full form. + entityRef = stringifyEntityRef( + parseEntityRef(ref as string, { + defaultKind, + defaultNamespace, + }), + ); + } catch (err) { + // If the passed in value isn't an entity ref, do nothing. + } + + // We need to check against formData here as that's the previous value for this field. + if (formData.includes(ref) || allowArbitraryValues) { + return entityRef; + } + } + + return undefined; + }) + .filter(ref => ref !== undefined) as string[]; + + onChange(values); + }, + [onChange, formData, defaultKind, defaultNamespace, allowArbitraryValues], + ); + + useEffect(() => { + if (entities?.length === 1) { + onChange(stringifyEntityRef(entities[0])); + } + }, [entities, onChange]); + + return ( + 0 && !formData} + > + formData && formData.includes(stringifyEntityRef(e)), + ) ?? (allowArbitraryValues && formData ? formData.map(getLabel) : []) + } + loading={loading} + onChange={onSelect} + options={entities || []} + getOptionLabel={option => + // option can be a string due to freeSolo. + typeof option === 'string' + ? option + : humanizeEntityRef(option, { defaultKind, defaultNamespace })! + } + autoSelect + freeSolo={allowArbitraryValues} + renderInput={params => ( + + )} + /> + + ); +}; + +export const validateMultiEntityPickerValidation = ( + values: string[], + validation: FieldValidation, +) => { + values.forEach(value => { + try { + parseEntityRef(value); + } catch { + validation.addError(`${value} is not a valid entity ref`); + } + }); +}; + +/** + * Converts a special `{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: MultiEntityPickerFilterQuery, +): 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`. + * + * @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: MultiEntityPickerProps['uiSchema'], +): EntityFilterQuery | undefined { + const catalogFilter: MultiEntityPickerUiOptions['catalogFilter'] | undefined = + uiSchema['ui:options']?.catalogFilter; + + if (!catalogFilter) { + return undefined; + } + + if (Array.isArray(catalogFilter)) { + return catalogFilter.map(convertSchemaFiltersToQuery); + } + + return convertSchemaFiltersToQuery(catalogFilter); +} diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/index.ts b/plugins/scaffolder/src/components/fields/MultiEntityPicker/index.ts new file mode 100644 index 0000000000..3b82568152 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 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 { MultiEntityPickerExtension } from './extensions'; + +export { + MultiEntityPickerFieldSchema, + type MultiEntityPickerUiOptions, +} from './schema'; diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts b/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts new file mode 100644 index 0000000000..b6292a46b7 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2022 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 { makeFieldSchemaFromZod } from '@backstage/plugin-scaffolder'; +import { z } from 'zod'; + +/** + * @public + */ +export const entityQueryFilterExpressionSchema = z.record( + z + .string() + .or(z.object({ exists: z.boolean().optional() })) + .or(z.array(z.string())), +); + +/** + * @public + */ +export const MultiEntityPickerFieldSchema = makeFieldSchemaFromZod( + z.array(z.string()), + z.object({ + defaultKind: z + .string() + .optional() + .describe( + 'The default entity kind. Options of this kind will not be prefixed.', + ), + allowArbitraryValues: z + .boolean() + .optional() + .describe('Whether to allow arbitrary user input. Defaults to true'), + defaultNamespace: z + .union([z.string(), z.literal(false)]) + .optional() + .describe( + 'The default namespace. Options with this namespace will not be prefixed.', + ), + catalogFilter: z + .array(entityQueryFilterExpressionSchema) + .or(entityQueryFilterExpressionSchema) + .optional() + .describe('List of key-value filter expression for entities'), + }), +); + +/** + * The input props that can be specified under `ui:options` for the + * `EntityPicker` field extension. + * + * @public + */ +export type MultiEntityPickerUiOptions = + typeof MultiEntityPickerFieldSchema.uiOptionsType; + +export type MultiEntityPickerProps = typeof MultiEntityPickerFieldSchema.type; + +export const MultiEntityPickerSchema = MultiEntityPickerFieldSchema.schema; + +export type MultiEntityPickerFilterQuery = z.TypeOf< + typeof entityQueryFilterExpressionSchema +>; + +export type MultiEntityPickerFilterQueryValue = + MultiEntityPickerFilterQuery[keyof MultiEntityPickerFilterQuery]; diff --git a/plugins/scaffolder/src/plugin.tsx b/plugins/scaffolder/src/plugin.tsx index 7fa7dc1521..c053bfacd9 100644 --- a/plugins/scaffolder/src/plugin.tsx +++ b/plugins/scaffolder/src/plugin.tsx @@ -33,6 +33,11 @@ import { OwnerPicker, OwnerPickerSchema, } from './components/fields/OwnerPicker/OwnerPicker'; +import { + MultiEntityPicker, + MultiEntityPickerSchema, + validateMultiEntityPickerValidation, +} from './components/fields/MultiEntityPicker/MultiEntityPicker'; import { repoPickerValidation } from './components/fields/RepoUrlPicker'; import { RepoUrlPicker, @@ -134,6 +139,20 @@ export const EntityNamePickerFieldExtension = scaffolderPlugin.provide( }), ); +/** + * A field extension for selecting multiple entities that exists in the Catalog. + * + * @public + */ +export const MultiEntityPickerFieldExtension = scaffolderPlugin.provide( + createScaffolderFieldExtension({ + component: MultiEntityPicker, + name: 'MultiEntityPicker', + schema: MultiEntityPickerSchema, + validation: validateMultiEntityPickerValidation, + }), +); + /** * The field extension which provides the ability to select a RepositoryUrl. * Currently, this is an encoded URL that looks something like the following `github.com?repo=myRepoName&owner=backstage`. From 246da2197b1d15af882e6bb62555c047b2a98fdb Mon Sep 17 00:00:00 2001 From: Marc Rooding Date: Wed, 6 Sep 2023 11:55:04 +0200 Subject: [PATCH 02/12] fix: remove redundant export Signed-off-by: Marc Rooding --- .../scaffolder/src/components/fields/MultiEntityPicker/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/index.ts b/plugins/scaffolder/src/components/fields/MultiEntityPicker/index.ts index 3b82568152..9a597b9b97 100644 --- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/index.ts +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/index.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -export { MultiEntityPickerExtension } from './extensions'; - export { MultiEntityPickerFieldSchema, type MultiEntityPickerUiOptions, From 9f617f4b48ebefe4dd488c89823e03bc9f389a8f Mon Sep 17 00:00:00 2001 From: Marc Rooding Date: Sat, 30 Sep 2023 16:49:20 +0200 Subject: [PATCH 03/12] test: unit tests for the MultiEntityPicker Signed-off-by: Marc Rooding --- .../MultiEntityPicker.test.tsx | 693 ++++++++++++++++++ .../fields/MultiEntityPicker/schema.ts | 2 +- 2 files changed, 694 insertions(+), 1 deletion(-) create mode 100644 plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx new file mode 100644 index 0000000000..d1e66a2a22 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx @@ -0,0 +1,693 @@ +/* + * Copyright 2020 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 { 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'; +import { FieldProps } from '@rjsf/core'; +import { fireEvent, screen } from '@testing-library/react'; +import React from 'react'; +import { MultiEntityPicker } from './MultiEntityPicker'; +import { MultiEntityPickerProps } from './schema'; + +const makeEntity = (kind: string, namespace: string, name: string): Entity => ({ + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind, + metadata: { namespace, name }, +}); + +describe('', () => { + let entities: Entity[]; + const onChange = jest.fn(); + const schema = {}; + const required = false; + let uiSchema: MultiEntityPickerProps['uiSchema']; + const rawErrors: string[] = []; + const formData: string[] = []; + + let props: FieldProps; + + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(async () => ({ items: entities })), + addLocation: jest.fn(), + getLocationByRef: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType>; + + beforeEach(() => { + entities = [ + makeEntity('Group', 'default', 'team-a'), + makeEntity('Group', 'default', 'squad-b'), + ]; + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + }); + + // afterEach(() => jest.resetAllMocks()); + + describe('without allowedKinds and catalogFilter', () => { + beforeEach(() => { + uiSchema = { 'ui:options': {} }; + props = { + onChange, + schema, + required, + uiSchema, + rawErrors, + formData, + } as unknown as FieldProps; + + catalogApi.getEntities.mockResolvedValue({ items: entities }); + }); + + it('searches for all entities', async () => { + await renderInTestApp( + + + , + ); + + expect(catalogApi.getEntities).toHaveBeenCalledWith(undefined); + }); + + it('updates even if there is not an exact match', async () => { + const { getByRole } = await renderInTestApp( + + + , + ); + + const input = getByRole('textbox'); + + fireEvent.change(input, { target: { value: 'squ' } }); + fireEvent.blur(input); + + expect(onChange).toHaveBeenCalledWith(['squ']); + }); + }); + + describe('with catalogFilter', () => { + beforeEach(() => { + uiSchema = { + 'ui:options': { + catalogFilter: [ + { + kind: ['Group'], + 'metadata.name': 'test-entity', + }, + { + kind: ['User'], + 'metadata.name': 'test-entity', + }, + ], + }, + }; + props = { + onChange, + schema, + required, + uiSchema, + rawErrors, + formData, + } as unknown as FieldProps; + + catalogApi.getEntities.mockResolvedValue({ items: entities }); + }); + + it('searches for a specific group entity', async () => { + await renderInTestApp( + + + , + ); + + expect(catalogApi.getEntities).toHaveBeenCalledWith({ + filter: [ + { + kind: ['Group'], + 'metadata.name': 'test-entity', + }, + { + kind: ['User'], + 'metadata.name': 'test-entity', + }, + ], + }); + }); + + 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', () => { + beforeEach(() => { + uiSchema = { + 'ui:options': { + catalogFilter: [ + { + kind: ['Group'], + 'metadata.name': 'test-group', + }, + ], + allowedKinds: ['User'], + }, + }; + props = { + onChange, + schema, + required, + uiSchema, + rawErrors, + formData, + } as unknown as FieldProps; + + catalogApi.getEntities.mockResolvedValue({ items: entities }); + }); + + it('searches for a Group entity', async () => { + await renderInTestApp( + + + , + ); + + expect(catalogApi.getEntities).toHaveBeenCalledWith({ + filter: [ + { + kind: ['Group'], + 'metadata.name': 'test-group', + }, + ], + }); + }); + }); + + describe('uses full entity ref', () => { + beforeEach(() => { + uiSchema = { + 'ui:options': { + defaultKind: 'Group', + }, + }; + props = { + onChange, + schema, + required, + uiSchema, + rawErrors, + formData, + } as unknown as FieldProps; + + catalogApi.getEntities.mockResolvedValue({ items: entities }); + }); + + it('returns the full entityRef when entity exists in the list', async () => { + const { getByRole } = await renderInTestApp( + + + , + ); + + const input = getByRole('textbox'); + + fireEvent.change(input, { target: { value: 'team-a' } }); + fireEvent.blur(input); + + expect(onChange).toHaveBeenCalledWith(['group:default/team-a']); + }); + + it('returns the full entityRef when entity does not exist in the list', async () => { + const { getByRole } = await renderInTestApp( + + + , + ); + + const input = getByRole('textbox'); + + fireEvent.change(input, { target: { value: 'team-b' } }); + fireEvent.blur(input); + + expect(onChange).toHaveBeenCalledWith(['group:default/team-b']); + }); + }); + + describe('Required MultiEntityPicker', () => { + beforeEach(() => { + uiSchema = { + 'ui:options': { + catalogFilter: [ + { + kind: ['Group'], + 'metadata.name': 'test-entity', + }, + { + kind: ['User'], + 'metadata.name': 'test-entity', + }, + ], + }, + }; + props = { + onChange, + schema, + required: true, + uiSchema, + rawErrors, + formData, + } as unknown as FieldProps; + + catalogApi.getEntities.mockResolvedValue({ items: entities }); + }); + + it('User enters clear input', async () => { + await renderInTestApp( + + +
Outside
+
, + ); + + const input = screen.getByRole('textbox'); + + fireEvent.change(input, { target: { value: '' } }); + fireEvent.blur(input); + + expect(input).toHaveValue(''); + }); + + it('User selects item', async () => { + await renderInTestApp( + + + , + ); + + const input = screen.getByRole('textbox'); + + fireEvent.change(input, { target: { value: 'team-a' } }); + fireEvent.blur(input); + + expect(onChange).toHaveBeenCalledWith(['team-a']); + }); + + it('User selects item and enters clear input', async () => { + await renderInTestApp( + + +
Outside
+
, + ); + + // Open the Autocomplete dropdown + const input = screen.getByRole('textbox'); + fireEvent.click(input); + + // Select an option from the dropdown + fireEvent.change(input, { target: { value: 'team-a' } }); + + // Close the dropdown by clicking outside the Autocomplete component + const outside = screen.getByTestId('outside'); + fireEvent.mouseDown(outside); + + // Click back into the Autocomplete component + fireEvent.click(input); + + // Verify that the selected option is displayed in the input + expect(input).toHaveValue('team-a'); + + // Click the Clear button to clear the input + const clearButton = screen.getByLabelText('Clear'); + + fireEvent.click(clearButton); + + // Verify that the input is empty + expect(input).toHaveValue(''); + + // Verify that the handleChange function was called with an empty array + expect(onChange).toHaveBeenCalledWith([]); + }); + }); + + describe('Optional MultiEntityPicker', () => { + beforeEach(() => { + uiSchema = { + 'ui:options': { + catalogFilter: [ + { + kind: ['Group'], + 'metadata.name': 'test-entity', + }, + { + kind: ['User'], + 'metadata.name': 'test-entity', + }, + ], + }, + }; + props = { + onChange, + schema, + required: false, + uiSchema, + rawErrors, + formData, + } as unknown as FieldProps; + + catalogApi.getEntities.mockResolvedValue({ items: entities }); + }); + + it('User enters clear input', async () => { + await renderInTestApp( + + +
Outside
+
, + ); + + const input = screen.getByRole('textbox'); + + fireEvent.change(input, { target: { value: '' } }); + fireEvent.blur(input); + + expect(input).toHaveValue(''); + }); + + it('User selects item', async () => { + await renderInTestApp( + + + , + ); + + const input = screen.getByRole('textbox'); + + fireEvent.change(input, { target: { value: 'team-a' } }); + fireEvent.blur(input); + + expect(onChange).toHaveBeenCalledWith(['team-a']); + }); + + it('User selects item and enters clear input', async () => { + await renderInTestApp( + + +
Outside
+
, + ); + + // Open the Autocomplete dropdown + const input = screen.getByRole('textbox'); + fireEvent.click(input); + + // Select an option from the dropdown + fireEvent.change(input, { target: { value: 'team-a' } }); + + // Close the dropdown by clicking outside the Autocomplete component + const outside = screen.getByTestId('outside'); + fireEvent.mouseDown(outside); + + // Click back into the Autocomplete component + fireEvent.click(input); + + // Verify that the selected option is displayed in the input + expect(input).toHaveValue('team-a'); + + // Click the Clear button to clear the input + const clearButton = screen.getByLabelText('Clear'); + fireEvent.click(clearButton); + + // Verify that the input is empty + expect(input).toHaveValue(''); + + // Verify that the handleChange function was called with an empty array + expect(onChange).toHaveBeenCalledWith([]); + }); + }); + + describe('Required Free Solo', () => { + beforeEach(() => { + uiSchema = { + 'ui:options': { + catalogFilter: [ + { + kind: ['Group'], + 'metadata.name': 'test-entity', + }, + { + kind: ['User'], + 'metadata.name': 'test-entity', + }, + ], + }, + allowArbitraryValues: true, + }; + props = { + onChange, + schema, + required: true, + uiSchema, + rawErrors, + formData, + } as unknown as FieldProps; + + catalogApi.getEntities.mockResolvedValue({ items: entities }); + }); + + it('User enters clear input', async () => { + await renderInTestApp( + + +
Outside
+
, + ); + + const input = screen.getByRole('textbox'); + + fireEvent.change(input, { target: { value: '' } }); + fireEvent.blur(input); + + expect(input).toHaveValue(''); + }); + + it('User selects item', async () => { + await renderInTestApp( + + + , + ); + + const input = screen.getByRole('textbox'); + + fireEvent.change(input, { target: { value: 'team-a' } }); + fireEvent.blur(input); + + expect(onChange).toHaveBeenCalledWith(['team-a']); + }); + + it('User selects item and enters clear input', async () => { + await renderInTestApp( + + +
Outside
+
, + ); + + // Open the Autocomplete dropdown + const input = screen.getByRole('textbox'); + fireEvent.click(input); + + // Select an option from the dropdown + fireEvent.change(input, { target: { value: 'team-a' } }); + + // Close the dropdown by clicking outside the Autocomplete component + const outside = screen.getByTestId('outside'); + fireEvent.mouseDown(outside); + + // Click back into the Autocomplete component + fireEvent.click(input); + + // Verify that the selected option is displayed in the input + expect(input).toHaveValue('team-a'); + + // Click the Clear button to clear the input + const clearButton = screen.getByLabelText('Clear'); + fireEvent.click(clearButton); + + // Verify that the input is empty + expect(input).toHaveValue(''); + + // Verify that the handleChange function was called with an empty array + expect(onChange).toHaveBeenCalledWith([]); + }); + }); + + describe('Optional Free Solo', () => { + beforeEach(() => { + uiSchema = { + 'ui:options': { + catalogFilter: [ + { + kind: ['Group'], + 'metadata.name': 'test-entity', + }, + { + kind: ['User'], + 'metadata.name': 'test-entity', + }, + ], + }, + allowArbitraryValues: true, + }; + props = { + onChange, + schema, + required: false, + uiSchema, + rawErrors, + formData, + } as unknown as FieldProps; + + catalogApi.getEntities.mockResolvedValue({ items: entities }); + }); + + it('User enters clear input', async () => { + await renderInTestApp( + + +
Outside
+
, + ); + + const input = screen.getByRole('textbox'); + + fireEvent.change(input, { target: { value: '' } }); + fireEvent.blur(input); + + expect(input).toHaveValue(''); + }); + + it('User selects item', async () => { + await renderInTestApp( + + + , + ); + + const input = screen.getByRole('textbox'); + + fireEvent.change(input, { target: { value: 'team-a' } }); + fireEvent.blur(input); + + expect(onChange).toHaveBeenCalledWith(['team-a']); + }); + + it('User selects item and enters clear input', async () => { + await renderInTestApp( + + +
Outside
+
, + ); + + // Open the Autocomplete dropdown + const input = screen.getByRole('textbox'); + fireEvent.click(input); + + // Select an option from the dropdown + fireEvent.change(input, { target: { value: 'team-a' } }); + + // Close the dropdown by clicking outside the Autocomplete component + const outside = screen.getByTestId('outside'); + fireEvent.mouseDown(outside); + + // Click back into the Autocomplete component + fireEvent.click(input); + + // Verify that the selected option is displayed in the input + expect(input).toHaveValue('team-a'); + + // Click the Clear button to clear the input + const clearButton = screen.getByLabelText('Clear'); + fireEvent.click(clearButton); + + // Verify that the input is empty + expect(input).toHaveValue(''); + + // Verify that the handleChange function was called with an empty array + expect(onChange).toHaveBeenCalledWith([]); + }); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts b/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts index b6292a46b7..00ce4c90c2 100644 --- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts @@ -13,8 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { makeFieldSchemaFromZod } from '@backstage/plugin-scaffolder'; import { z } from 'zod'; +import { makeFieldSchemaFromZod } from '../utils'; /** * @public From a2e19a5915d4a68041472c3fad13269fffd95d45 Mon Sep 17 00:00:00 2001 From: Marc Rooding Date: Tue, 7 Nov 2023 07:56:51 +0100 Subject: [PATCH 04/12] Update .changeset/metal-elephants-sit.md Co-authored-by: Ben Lambert Signed-off-by: Marc Rooding --- .changeset/metal-elephants-sit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/metal-elephants-sit.md b/.changeset/metal-elephants-sit.md index 17182ff1ea..093936da3b 100644 --- a/.changeset/metal-elephants-sit.md +++ b/.changeset/metal-elephants-sit.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder': minor --- -Introduced a new MultiEntityPicker field that supports selecting multiple Entities +Introduced a new `MultiEntityPicker` field that supports selecting multiple Entities From 5dd5792c5eadcff84868d29d44346e9bd996be11 Mon Sep 17 00:00:00 2001 From: Marc Rooding Date: Tue, 7 Nov 2023 08:07:35 +0100 Subject: [PATCH 05/12] chore: export MultiEntityPicker Signed-off-by: Marc Rooding --- plugins/scaffolder/src/components/fields/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/components/fields/index.ts b/plugins/scaffolder/src/components/fields/index.ts index 7f119f27be..e8a7b8cc91 100644 --- a/plugins/scaffolder/src/components/fields/index.ts +++ b/plugins/scaffolder/src/components/fields/index.ts @@ -19,5 +19,5 @@ export * from './RepoUrlPicker'; export * from './OwnedEntityPicker'; export * from './EntityTagsPicker'; export * from './MyGroupsPicker'; - +export * from './MultiEntityPicker'; export { type FieldSchema, makeFieldSchemaFromZod } from './utils'; From d9af045845497f01e2a2ad3b57606a051af9be48 Mon Sep 17 00:00:00 2001 From: Marc Rooding Date: Tue, 7 Nov 2023 08:07:53 +0100 Subject: [PATCH 06/12] chore: update api report Signed-off-by: Marc Rooding --- plugins/scaffolder/api-report.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 4df1eff430..fdeeddf4f3 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -210,6 +210,38 @@ export function makeFieldSchemaFromZod< : never >; +// @public (undocumented) +export const MultiEntityPickerFieldSchema: FieldSchema< + string[], + { + defaultKind?: string | undefined; + allowArbitraryValues?: boolean | undefined; + defaultNamespace?: string | false | undefined; + catalogFilter?: + | Record< + string, + | string + | string[] + | { + exists?: boolean | undefined; + } + > + | Record< + string, + | string + | string[] + | { + exists?: boolean | undefined; + } + >[] + | undefined; + } +>; + +// @public +export type MultiEntityPickerUiOptions = + typeof MultiEntityPickerFieldSchema.uiOptionsType; + // @public export const MyGroupsPickerFieldExtension: FieldExtensionComponent_2< string, From 817dc854eb4cbcb65e48b916caa0c872bad010f3 Mon Sep 17 00:00:00 2001 From: Marc Rooding Date: Wed, 8 Nov 2023 08:33:54 +0100 Subject: [PATCH 07/12] chore: fix typing issue Signed-off-by: Marc Rooding --- .../components/fields/MultiEntityPicker/MultiEntityPicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx index 0f27acca9a..6032db194a 100644 --- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx @@ -133,7 +133,7 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => { useEffect(() => { if (entities?.length === 1) { - onChange(stringifyEntityRef(entities[0])); + onChange([stringifyEntityRef(entities[0])]); } }, [entities, onChange]); From 3029aa71032c4fcfea6ee1bc8cc094f703b4ad0f Mon Sep 17 00:00:00 2001 From: Marc Rooding Date: Thu, 14 Dec 2023 19:06:03 +0100 Subject: [PATCH 08/12] fix: change import for FieldValidation Signed-off-by: Marc Rooding --- .../components/fields/MultiEntityPicker/MultiEntityPicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx index 6032db194a..68bc729024 100644 --- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx @@ -34,7 +34,7 @@ import Autocomplete, { } from '@material-ui/lab/Autocomplete'; import React, { useCallback, useEffect } from 'react'; import useAsync from 'react-use/lib/useAsync'; -import { FieldValidation } from '@rjsf/core'; +import { FieldValidation } from '@rjsf/utils'; import { MultiEntityPickerFilterQueryValue, MultiEntityPickerProps, From 69866abb308bd5166102c32ef1995c68d5d6063b Mon Sep 17 00:00:00 2001 From: Marc Rooding Date: Sat, 16 Dec 2023 07:24:08 +0100 Subject: [PATCH 09/12] fix: import FieldProps from @rjsf/utils Signed-off-by: Marc Rooding --- .../fields/MultiEntityPicker/MultiEntityPicker.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx index d1e66a2a22..858479f9bc 100644 --- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx @@ -18,7 +18,7 @@ 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'; -import { FieldProps } from '@rjsf/core'; +import { FieldProps } from '@rjsf/utils'; import { fireEvent, screen } from '@testing-library/react'; import React from 'react'; import { MultiEntityPicker } from './MultiEntityPicker'; From e5f8b50ba08aaf1ade17bd6db2d4d10b4816b560 Mon Sep 17 00:00:00 2001 From: Marc Rooding Date: Sat, 16 Dec 2023 07:42:04 +0100 Subject: [PATCH 10/12] fix: adjust typing Signed-off-by: Marc Rooding --- .../fields/MultiEntityPicker/MultiEntityPicker.test.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx index 858479f9bc..bc0a5ed32d 100644 --- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx @@ -18,11 +18,12 @@ 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'; -import { FieldProps } from '@rjsf/utils'; + import { fireEvent, screen } from '@testing-library/react'; import React from 'react'; import { MultiEntityPicker } from './MultiEntityPicker'; import { MultiEntityPickerProps } from './schema'; +import { ScaffolderRJSFFieldProps as FieldProps } from '@backstage/plugin-scaffolder-react'; const makeEntity = (kind: string, namespace: string, name: string): Entity => ({ apiVersion: 'scaffolder.backstage.io/v1beta3', @@ -39,7 +40,7 @@ describe('', () => { const rawErrors: string[] = []; const formData: string[] = []; - let props: FieldProps; + let props: FieldProps; const catalogApi: jest.Mocked = { getLocationById: jest.fn(), @@ -64,7 +65,7 @@ describe('', () => { ); }); - // afterEach(() => jest.resetAllMocks()); + afterEach(() => jest.resetAllMocks()); describe('without allowedKinds and catalogFilter', () => { beforeEach(() => { @@ -76,7 +77,7 @@ describe('', () => { uiSchema, rawErrors, formData, - } as unknown as FieldProps; + } as unknown as FieldProps; catalogApi.getEntities.mockResolvedValue({ items: entities }); }); From f5fc7eeb33dee48dccf09274cc600bb64ea198c5 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 23 Jan 2024 13:41:54 +0100 Subject: [PATCH 11/12] chore: refactoring how the exports work, should only export the extension Signed-off-by: blam --- plugins/scaffolder/api-report.md | 8 ++------ .../fields/MultiEntityPicker/MultiEntityPicker.tsx | 2 -- .../src/components/fields/MultiEntityPicker/schema.ts | 8 -------- plugins/scaffolder/src/components/fields/index.ts | 2 +- plugins/scaffolder/src/index.ts | 1 + 5 files changed, 4 insertions(+), 17 deletions(-) diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index fdeeddf4f3..ccea88e27c 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -210,8 +210,8 @@ export function makeFieldSchemaFromZod< : never >; -// @public (undocumented) -export const MultiEntityPickerFieldSchema: FieldSchema< +// @public +export const MultiEntityPickerFieldExtension: FieldExtensionComponent_2< string[], { defaultKind?: string | undefined; @@ -238,10 +238,6 @@ export const MultiEntityPickerFieldSchema: FieldSchema< } >; -// @public -export type MultiEntityPickerUiOptions = - typeof MultiEntityPickerFieldSchema.uiOptionsType; - // @public export const MyGroupsPickerFieldExtension: FieldExtensionComponent_2< string, diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx index 68bc729024..d850bbd435 100644 --- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx @@ -47,8 +47,6 @@ export { MultiEntityPickerSchema } from './schema'; /** * The underlying component that is rendered in the form for the `MultiEntityPicker` * field extension. - * - * @public */ export const MultiEntityPicker = (props: MultiEntityPickerProps) => { const { diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts b/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts index 00ce4c90c2..2621157d02 100644 --- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts @@ -16,9 +16,6 @@ import { z } from 'zod'; import { makeFieldSchemaFromZod } from '../utils'; -/** - * @public - */ export const entityQueryFilterExpressionSchema = z.record( z .string() @@ -26,9 +23,6 @@ export const entityQueryFilterExpressionSchema = z.record( .or(z.array(z.string())), ); -/** - * @public - */ export const MultiEntityPickerFieldSchema = makeFieldSchemaFromZod( z.array(z.string()), z.object({ @@ -59,8 +53,6 @@ export const MultiEntityPickerFieldSchema = makeFieldSchemaFromZod( /** * The input props that can be specified under `ui:options` for the * `EntityPicker` field extension. - * - * @public */ export type MultiEntityPickerUiOptions = typeof MultiEntityPickerFieldSchema.uiOptionsType; diff --git a/plugins/scaffolder/src/components/fields/index.ts b/plugins/scaffolder/src/components/fields/index.ts index e8a7b8cc91..7f119f27be 100644 --- a/plugins/scaffolder/src/components/fields/index.ts +++ b/plugins/scaffolder/src/components/fields/index.ts @@ -19,5 +19,5 @@ export * from './RepoUrlPicker'; export * from './OwnedEntityPicker'; export * from './EntityTagsPicker'; export * from './MyGroupsPicker'; -export * from './MultiEntityPicker'; + export { type FieldSchema, makeFieldSchemaFromZod } from './utils'; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index ee91d2988c..d809ec00f9 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -29,6 +29,7 @@ export { OwnedEntityPickerFieldExtension, MyGroupsPickerFieldExtension, RepoUrlPickerFieldExtension, + MultiEntityPickerFieldExtension, ScaffolderPage, scaffolderPlugin, } from './plugin'; From 71be226f994387142065fa0bc66eba02370b6f34 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 23 Jan 2024 13:43:53 +0100 Subject: [PATCH 12/12] chore: update the default field extensions Signed-off-by: blam --- plugins/scaffolder/src/extensions/default.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plugins/scaffolder/src/extensions/default.ts b/plugins/scaffolder/src/extensions/default.ts index 2e4b7032de..a59f3e08e3 100644 --- a/plugins/scaffolder/src/extensions/default.ts +++ b/plugins/scaffolder/src/extensions/default.ts @@ -45,6 +45,11 @@ import { } from '../components/fields/MyGroupsPicker/MyGroupsPicker'; import { SecretInput } from '../components/fields/SecretInput'; +import { + MultiEntityPicker, + MultiEntityPickerSchema, + validateMultiEntityPickerValidation, +} from '../components/fields/MultiEntityPicker/MultiEntityPicker'; export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [ { @@ -88,4 +93,10 @@ export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [ component: SecretInput, name: 'Secret', }, + { + component: MultiEntityPicker, + name: 'MultiEntityPicker', + schema: MultiEntityPickerSchema, + validation: validateMultiEntityPickerValidation, + }, ];