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