diff --git a/.changeset/eighty-chairs-roll.md b/.changeset/eighty-chairs-roll.md
new file mode 100644
index 0000000000..c981229378
--- /dev/null
+++ b/.changeset/eighty-chairs-roll.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder': minor
+---
+
+Update `EntityPicker` to use the fully qualified entity ref instead of the humanized version.
diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx
index b6d6688a09..3cc45e6412 100644
--- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx
+++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx
@@ -236,4 +236,54 @@ describe('', () => {
});
});
});
+
+ 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');
+ });
+ });
});
diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx
index 48e60b60ad..48d6453ab9 100644
--- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx
+++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx
@@ -14,6 +14,11 @@
* limitations under the License.
*/
import { type EntityFilterQuery } from '@backstage/catalog-client';
+import {
+ Entity,
+ parseEntityRef,
+ stringifyEntityRef,
+} from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import {
catalogApiRef,
@@ -21,7 +26,9 @@ import {
} from '@backstage/plugin-catalog-react';
import { TextField } from '@material-ui/core';
import FormControl from '@material-ui/core/FormControl';
-import Autocomplete from '@material-ui/lab/Autocomplete';
+import Autocomplete, {
+ AutocompleteChangeReason,
+} from '@material-ui/lab/Autocomplete';
import React, { useCallback, useEffect } from 'react';
import useAsync from 'react-use/lib/useAsync';
import { EntityPickerProps } from './schema';
@@ -51,32 +58,72 @@ export const EntityPicker = (props: EntityPickerProps) => {
(allowedKinds && { kind: allowedKinds });
const defaultKind = uiSchema['ui:options']?.defaultKind;
- const defaultNamespace = uiSchema['ui:options']?.defaultNamespace;
+ const defaultNamespace =
+ uiSchema['ui:options']?.defaultNamespace || undefined;
const catalogApi = useApi(catalogApiRef);
- const { value: entities, loading } = useAsync(() =>
- catalogApi.getEntities(
+ const { value: entities, loading } = useAsync(async () => {
+ const { items } = await catalogApi.getEntities(
catalogFilter ? { filter: catalogFilter } : undefined,
- ),
- );
+ );
+ return items;
+ });
+ const allowArbitraryValues =
+ uiSchema['ui:options']?.allowArbitraryValues ?? true;
- const entityRefs = entities?.items.map(e =>
- humanizeEntityRef(e, { defaultKind, defaultNamespace }),
+ const getLabel = useCallback(
+ (ref: string) => {
+ try {
+ return humanizeEntityRef(
+ parseEntityRef(ref, { defaultKind, defaultNamespace }),
+ {
+ defaultKind,
+ defaultNamespace,
+ },
+ );
+ } catch (err) {
+ return ref;
+ }
+ },
+ [defaultKind, defaultNamespace],
);
const onSelect = useCallback(
- (_: any, value: string | null) => {
- onChange(value ?? undefined);
+ (_: 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) : '');
+ } else {
+ 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 !== ref || allowArbitraryValues) {
+ onChange(entityRef);
+ }
+ }
+ }
},
- [onChange],
+ [onChange, formData, defaultKind, defaultNamespace, allowArbitraryValues],
);
useEffect(() => {
- if (entityRefs?.length === 1) {
- onChange(entityRefs[0]);
+ if (entities?.length === 1) {
+ onChange(stringifyEntityRef(entities[0]));
}
- }, [entityRefs, onChange]);
+ }, [entities, onChange]);
return (
{
error={rawErrors?.length > 0 && !formData}
>
stringifyEntityRef(e) === formData) ??
+ (allowArbitraryValues && formData ? getLabel(formData) : '')
+ }
loading={loading}
onChange={onSelect}
- options={entityRefs || []}
+ options={entities || []}
+ getOptionLabel={option =>
+ // option can be a string due to freeSolo.
+ typeof option === 'string'
+ ? option
+ : humanizeEntityRef(option, { defaultKind, defaultNamespace })!
+ }
autoSelect
- freeSolo={uiSchema['ui:options']?.allowArbitraryValues ?? true}
+ freeSolo={allowArbitraryValues}
renderInput={params => (
{
const {
- onChange,
schema: { title = 'Entity', description = 'An entity from the catalog' },
- required,
uiSchema,
- rawErrors,
- formData,
- idSchema,
+ required,
} = props;
+ const identityApi = useApi(identityApiRef);
+ const { loading, value: identityRefs } = useAsync(async () => {
+ const identity = await identityApi.getBackstageIdentity();
+ return identity.ownershipEntityRefs;
+ });
+
const allowedKinds = uiSchema['ui:options']?.allowedKinds;
- const defaultKind = uiSchema['ui:options']?.defaultKind;
- const defaultNamespace = uiSchema['ui:options']?.defaultNamespace;
- const allowArbitraryValues =
- uiSchema['ui:options']?.allowArbitraryValues ?? true;
- const { ownedEntities, loading } = useOwnedEntities(allowedKinds);
-
- const entityRefs = ownedEntities?.items
- .map(e => humanizeEntityRef(e, { defaultKind, defaultNamespace }))
- .filter(n => n);
-
- const onSelect = (_: any, value: string | null) => {
- onChange(value || '');
- };
-
- return (
- 0 && !formData}
- >
+ if (loading)
+ return (
(
)}
+ options={[]}
/>
-
+ );
+
+ return (
+
);
};
-
-/**
- * Takes the relevant parts of the Backstage identity, and translates them into
- * a list of entities which are owned by the user. Takes an optional parameter
- * to filter the entities based on allowedKinds
- *
- *
- * @param allowedKinds - Array of allowed kinds to filter the entities
- */
-function useOwnedEntities(allowedKinds?: string[]): {
- loading: boolean;
- ownedEntities: GetEntitiesResponse | undefined;
-} {
- const identityApi = useApi(identityApiRef);
- const catalogApi = useApi(catalogApiRef);
-
- const { loading, value: refs } = useAsync(async () => {
- const identity = await identityApi.getBackstageIdentity();
- const identityRefs = identity.ownershipEntityRefs;
- const catalogs = await catalogApi.getEntities(
- allowedKinds
- ? {
- filter: {
- kind: allowedKinds,
- [`relations.${RELATION_OWNED_BY}`]: identityRefs || [],
- },
- }
- : {
- filter: {
- [`relations.${RELATION_OWNED_BY}`]: identityRefs || [],
- },
- },
- );
- return catalogs;
- }, []);
-
- const ownedEntities = useMemo(() => {
- return refs;
- }, [refs]);
-
- return useMemo(() => ({ loading, ownedEntities }), [loading, ownedEntities]);
-}