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