diff --git a/.changeset/metal-elephants-sit.md b/.changeset/metal-elephants-sit.md new file mode 100644 index 0000000000..093936da3b --- /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/api-report.md b/plugins/scaffolder/api-report.md index 4df1eff430..ccea88e27c 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -210,6 +210,34 @@ export function makeFieldSchemaFromZod< : never >; +// @public +export const MultiEntityPickerFieldExtension: FieldExtensionComponent_2< + 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 const MyGroupsPickerFieldExtension: FieldExtensionComponent_2< string, 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..bc0a5ed32d --- /dev/null +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx @@ -0,0 +1,694 @@ +/* + * 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 { 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', + 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/MultiEntityPicker.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx new file mode 100644 index 0000000000..d850bbd435 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx @@ -0,0 +1,259 @@ +/* + * 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/utils'; +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. + */ +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..9a597b9b97 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { + 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..2621157d02 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts @@ -0,0 +1,69 @@ +/* + * 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 { z } from 'zod'; +import { makeFieldSchemaFromZod } from '../utils'; + +export const entityQueryFilterExpressionSchema = z.record( + z + .string() + .or(z.object({ exists: z.boolean().optional() })) + .or(z.array(z.string())), +); + +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. + */ +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/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, + }, ]; 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'; 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`.