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`.