diff --git a/.changeset/long-ravens-listen.md b/.changeset/long-ravens-listen.md
new file mode 100644
index 0000000000..8300c12f77
--- /dev/null
+++ b/.changeset/long-ravens-listen.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder': minor
+---
+
+Fixed bug in EntityPicker component that allowed for empty values when field is required. This bug occurs only after a user fills the EntityPicker field, clears it, and then continues to the next form step.
diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx
index 937ff7d07a..598616aaa3 100644
--- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx
+++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx
@@ -19,13 +19,13 @@ 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 } from '@testing-library/react';
+import { fireEvent, screen } from '@testing-library/react';
import React from 'react';
import { EntityPicker } from './EntityPicker';
import { EntityPickerProps } from './schema';
const makeEntity = (kind: string, namespace: string, name: string): Entity => ({
- apiVersion: 'backstage.io/v1beta1',
+ apiVersion: 'scaffolder.backstage.io/v1beta3',
kind,
metadata: { namespace, name },
});
@@ -332,4 +332,394 @@ describe('', () => {
expect(onChange).toHaveBeenCalledWith('group:default/team-b');
});
});
+
+ describe('Required EntityPicker', () => {
+ 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(input).toHaveValue('team-a');
+ 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 undefined
+ expect(onChange).toHaveBeenCalledWith(undefined);
+ });
+ });
+
+ describe('Optional EntityPicker', () => {
+ 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(input).toHaveValue('team-a');
+ 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 undefined
+ expect(onChange).toHaveBeenCalledWith(undefined);
+ });
+ });
+
+ 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(input).toHaveValue('team-a');
+ 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 undefined
+ expect(onChange).toHaveBeenCalledWith(undefined);
+ });
+ });
+
+ 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(input).toHaveValue('team-a');
+ 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 undefined
+ expect(onChange).toHaveBeenCalledWith(undefined);
+ });
+ });
});
diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx
index aeb4a52f77..0f698919d5 100644
--- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx
+++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx
@@ -96,7 +96,8 @@ export const EntityPicker = (props: EntityPickerProps) => {
(_: any, ref: string | Entity | null, reason: AutocompleteChangeReason) => {
// ref can either be a string from free solo entry or
if (typeof ref !== 'string') {
- onChange(ref ? stringifyEntityRef(ref as Entity) : '');
+ // if ref does not exist: pass 'undefined' to trigger validation for required value
+ onChange(ref ? stringifyEntityRef(ref as Entity) : undefined);
} else {
if (reason === 'blur' || reason === 'create-option') {
// Add in default namespace, etc.