diff --git a/.changeset/two-coins-stop.md b/.changeset/two-coins-stop.md
new file mode 100644
index 0000000000..005ea81121
--- /dev/null
+++ b/.changeset/two-coins-stop.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder': patch
+---
+
+Prevent the MultiEntityPicker from removing options present in form state when new options are selected
diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx
index a02d793a34..fc824f20f9 100644
--- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx
+++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx
@@ -262,6 +262,71 @@ describe('', () => {
});
});
+ describe('with existing form data', () => {
+ beforeEach(() => {
+ uiSchema = { 'ui:options': {} };
+ props = {
+ onChange,
+ schema,
+ required,
+ uiSchema,
+ rawErrors,
+ formData: ['group:default/team-a'],
+ } as unknown as FieldProps;
+ });
+
+ it('preserves existing data on blur', async () => {
+ const { getByRole } = await renderInTestApp(
+
+
+ ,
+ );
+
+ const input = getByRole('textbox');
+
+ fireEvent.change(input, { target: { value: 'squ' } });
+ fireEvent.blur(input);
+
+ expect(onChange).toHaveBeenCalledWith(['group:default/team-a', 'squ']);
+ });
+
+ it('preserves existing data on value create', async () => {
+ const { getByRole } = await renderInTestApp(
+
+
+ ,
+ );
+
+ const input = getByRole('textbox');
+
+ fireEvent.change(input, { target: { value: 'squ' } });
+ fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
+
+ expect(onChange).toHaveBeenCalledWith(['group:default/team-a', 'squ']);
+ });
+
+ it('preserves existing data on selecting an existing option', async () => {
+ catalogApi.getEntities.mockResolvedValue({ items: entities });
+
+ const { getByRole } = await renderInTestApp(
+
+
+ ,
+ );
+
+ const input = getByRole('textbox');
+
+ fireEvent.mouseDown(input);
+ const optionA = screen.getByText('squad-b');
+ await userEvent.click(optionA as HTMLElement);
+
+ expect(onChange).toHaveBeenCalledWith([
+ 'group:default/team-a',
+ 'group:default/squad-b',
+ ]);
+ });
+ });
+
describe('uses full entity ref', () => {
beforeEach(() => {
uiSchema = {
diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx
index 57c4b87d6d..39130c4b78 100644
--- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx
+++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx
@@ -50,6 +50,12 @@ import { scaffolderTranslationRef } from '../../../translation';
export { MultiEntityPickerSchema } from './schema';
+// AutocompleteChangeReason events that can be triggered when a user inputs a freeSolo option
+const FREE_SOLO_EVENTS: readonly AutocompleteChangeReason[] = [
+ 'blur',
+ 'create-option',
+];
+
/**
* The underlying component that is rendered in the form for the `MultiEntityPicker`
* field extension.
@@ -110,29 +116,34 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
(_: any, refs: (string | Entity)[], reason: AutocompleteChangeReason) => {
const values = refs
.map(ref => {
+ // If the ref is not a string, then it was a selected option in the picker
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;
- }
+ // 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 (
+ // If value already matches what exists in form data, allow it
+ formData?.includes(ref) ||
+ // If arbitrary values are allowed and the reason is a free solo event, allow it
+ (allowArbitraryValues && FREE_SOLO_EVENTS.includes(reason))
+ ) {
+ return entityRef;
}
return undefined;