fix(scaffolder): prevent MultiEntityPicker from removing existing options from form data

Signed-off-by: Alec Jacobs <cajacobs5401@gmail.com>
This commit is contained in:
Alec Jacobs
2025-09-12 14:20:12 -07:00
parent 53150372e5
commit 7151260d27
3 changed files with 99 additions and 18 deletions
@@ -262,6 +262,71 @@ describe('<MultiEntityPicker />', () => {
});
});
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(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
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(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
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(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
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 = {
@@ -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;