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