From 42b2f6542199be17ce410003ac2d0255c47bca1b Mon Sep 17 00:00:00 2001 From: Aramis Sennyey Date: Thu, 9 Feb 2023 20:37:29 -0500 Subject: [PATCH 1/4] First try, need to figure out how to pass entity ref as form data. Signed-off-by: Aramis Sennyey --- .../src/components/EntityRefLink/humanize.ts | 9 ++++ .../fields/EntityPicker/EntityPicker.tsx | 45 +++++++++++-------- .../OwnedEntityPicker/OwnedEntityPicker.tsx | 15 +++---- 3 files changed, 42 insertions(+), 27 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityRefLink/humanize.ts b/plugins/catalog-react/src/components/EntityRefLink/humanize.ts index bfc8e2af84..9abf0d7978 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/humanize.ts +++ b/plugins/catalog-react/src/components/EntityRefLink/humanize.ts @@ -18,6 +18,7 @@ import { Entity, CompoundEntityRef, DEFAULT_NAMESPACE, + UserEntity, } from '@backstage/catalog-model'; /** @@ -36,11 +37,19 @@ export function humanizeEntityRef( let kind; let namespace; let name; + console.log(entityRef); if ('metadata' in entityRef) { kind = entityRef.kind; namespace = entityRef.metadata.namespace; name = entityRef.metadata.name; + + // Escapes when we know more about an entity. + if (entityRef.metadata.title) { + return entityRef.metadata.title; + } else if ((entityRef as UserEntity).spec?.profile?.displayName) { + return (entityRef as UserEntity).spec?.profile?.displayName; + } } else { kind = entityRef.kind; namespace = entityRef.namespace; diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx index 48e60b60ad..0dc3e39fc4 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ import { type EntityFilterQuery } from '@backstage/catalog-client'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef, @@ -22,7 +23,7 @@ import { import { TextField } from '@material-ui/core'; import FormControl from '@material-ui/core/FormControl'; import Autocomplete from '@material-ui/lab/Autocomplete'; -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import useAsync from 'react-use/lib/useAsync'; import { EntityPickerProps } from './schema'; @@ -45,6 +46,7 @@ export const EntityPicker = (props: EntityPickerProps) => { idSchema, } = props; const allowedKinds = uiSchema['ui:options']?.allowedKinds; + console.log(formData); const catalogFilter: EntityFilterQuery | undefined = uiSchema['ui:options']?.catalogFilter || @@ -54,29 +56,31 @@ export const EntityPicker = (props: EntityPickerProps) => { const defaultNamespace = uiSchema['ui:options']?.defaultNamespace; const catalogApi = useApi(catalogApiRef); + const [value, setValue] = useState(formData); - const { value: entities, loading } = useAsync(() => - catalogApi.getEntities( + const { value: entities, loading } = useAsync(async () => { + const { items } = await catalogApi.getEntities( catalogFilter ? { filter: catalogFilter } : undefined, - ), - ); - - const entityRefs = entities?.items.map(e => - humanizeEntityRef(e, { defaultKind, defaultNamespace }), - ); + ); + return items; + }); const onSelect = useCallback( - (_: any, value: string | null) => { - onChange(value ?? undefined); + (_: any, ref: string | Entity | null) => { + let entityRef: string = typeof ref === 'string' ? ref : ''; + if (typeof ref !== 'string') + entityRef = ref ? stringifyEntityRef(ref as Entity) : ''; + setValue(entityRef); + onChange(entityRef); }, - [onChange], + [onChange, setValue], ); 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} > e.metadata.name === formData)} loading={loading} onChange={onSelect} - options={entityRefs || []} + options={entities || []} + getOptionLabel={option => + typeof option === 'string' + ? option + : humanizeEntityRef(option, { defaultKind, defaultNamespace })! + } autoSelect freeSolo={uiSchema['ui:options']?.allowArbitraryValues ?? true} renderInput={params => ( diff --git a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx index 01a174a6a3..6d80972f1b 100644 --- a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { GetEntitiesResponse } from '@backstage/catalog-client'; -import { RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; import { identityApiRef, useApi } from '@backstage/core-plugin-api'; import { catalogApiRef, @@ -54,12 +54,9 @@ export const OwnedEntityPicker = (props: OwnedEntityPickerProps) => { 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 || ''); + const entities = ownedEntities?.items.filter(n => n); + const onSelect = (_: any, value: Entity | null) => { + onChange(value?.metadata.name || ''); }; return ( @@ -70,10 +67,10 @@ export const OwnedEntityPicker = (props: OwnedEntityPickerProps) => { > ( From f36840b4664ee1b061ad81479afcf5d4d2ffd6ba Mon Sep 17 00:00:00 2001 From: Aramis Sennyey Date: Fri, 10 Feb 2023 12:42:04 -0500 Subject: [PATCH 2/4] Update scaffolder filters to use humanizeEntityRef but stringifyEntityRef in the backend. Signed-off-by: Aramis Sennyey --- .../fields/EntityPicker/EntityPicker.tsx | 57 ++++++--- .../OwnedEntityPicker/OwnedEntityPicker.tsx | 117 ++++++------------ 2 files changed, 80 insertions(+), 94 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx index 0dc3e39fc4..2a36b9f8ef 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx @@ -14,7 +14,11 @@ * limitations under the License. */ import { type EntityFilterQuery } from '@backstage/catalog-client'; -import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { + Entity, + parseEntityRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef, @@ -22,8 +26,10 @@ 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 React, { useCallback, useEffect, useState } from 'react'; +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'; @@ -46,7 +52,6 @@ export const EntityPicker = (props: EntityPickerProps) => { idSchema, } = props; const allowedKinds = uiSchema['ui:options']?.allowedKinds; - console.log(formData); const catalogFilter: EntityFilterQuery | undefined = uiSchema['ui:options']?.catalogFilter || @@ -56,7 +61,6 @@ export const EntityPicker = (props: EntityPickerProps) => { const defaultNamespace = uiSchema['ui:options']?.defaultNamespace; const catalogApi = useApi(catalogApiRef); - const [value, setValue] = useState(formData); const { value: entities, loading } = useAsync(async () => { const { items } = await catalogApi.getEntities( @@ -64,16 +68,36 @@ export const EntityPicker = (props: EntityPickerProps) => { ); return items; }); + const allowArbitraryValues = + uiSchema['ui:options']?.allowArbitraryValues ?? true; const onSelect = useCallback( - (_: any, ref: string | Entity | null) => { - let entityRef: string = typeof ref === 'string' ? ref : ''; - if (typeof ref !== 'string') - entityRef = ref ? stringifyEntityRef(ref as Entity) : ''; - setValue(entityRef); - onChange(entityRef); + (_: any, ref: string | Entity | null, reason: AutocompleteChangeReason) => { + // if ref == string + if (typeof ref !== 'string') { + onChange(ref ? stringifyEntityRef(ref as Entity) : ''); + } else { + if (reason === 'blur') { + // 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: defaultNamespace || undefined, + }), + ); + } catch (err) { + // If the passed in value isn't an entity ref, do nothing. + } + if (formData !== entityRef) { + onChange(ref); + } + } + } }, - [onChange, setValue], + [onChange, formData, defaultKind, defaultNamespace], ); useEffect(() => { @@ -91,17 +115,22 @@ export const EntityPicker = (props: EntityPickerProps) => { e.metadata.name === formData)} + value={ + // Since free solo is usually enabled, attempt to + entities?.find(e => stringifyEntityRef(e) === formData) ?? + (allowArbitraryValues ? formData : '') + } loading={loading} onChange={onSelect} 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 entities = ownedEntities?.items.filter(n => n); - const onSelect = (_: any, value: Entity | null) => { - onChange(value?.metadata.name || ''); - }; - - 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]); -} From 26c27f4f808eaeab6ed7d6249ef25205d023174b Mon Sep 17 00:00:00 2001 From: Aramis Sennyey Date: Thu, 23 Feb 2023 10:44:10 -0500 Subject: [PATCH 3/4] Update EntityPicker to use stringifyEntityRef. Signed-off-by: Aramis Sennyey --- .../src/components/EntityRefLink/humanize.ts | 8 --- .../fields/EntityPicker/EntityPicker.test.tsx | 50 +++++++++++++++++++ .../fields/EntityPicker/EntityPicker.tsx | 38 ++++++++++---- 3 files changed, 79 insertions(+), 17 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityRefLink/humanize.ts b/plugins/catalog-react/src/components/EntityRefLink/humanize.ts index 9abf0d7978..9bbfcbc1ed 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/humanize.ts +++ b/plugins/catalog-react/src/components/EntityRefLink/humanize.ts @@ -37,19 +37,11 @@ export function humanizeEntityRef( let kind; let namespace; let name; - console.log(entityRef); if ('metadata' in entityRef) { kind = entityRef.kind; namespace = entityRef.metadata.namespace; name = entityRef.metadata.name; - - // Escapes when we know more about an entity. - if (entityRef.metadata.title) { - return entityRef.metadata.title; - } else if ((entityRef as UserEntity).spec?.profile?.displayName) { - return (entityRef as UserEntity).spec?.profile?.displayName; - } } else { kind = entityRef.kind; namespace = entityRef.namespace; 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 2a36b9f8ef..48d6453ab9 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx @@ -58,7 +58,8 @@ 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); @@ -71,13 +72,30 @@ export const EntityPicker = (props: EntityPickerProps) => { const allowArbitraryValues = uiSchema['ui:options']?.allowArbitraryValues ?? true; + const getLabel = useCallback( + (ref: string) => { + try { + return humanizeEntityRef( + parseEntityRef(ref, { defaultKind, defaultNamespace }), + { + defaultKind, + defaultNamespace, + }, + ); + } catch (err) { + return ref; + } + }, + [defaultKind, defaultNamespace], + ); + const onSelect = useCallback( (_: any, ref: string | Entity | null, reason: AutocompleteChangeReason) => { - // if ref == string + // 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') { + if (reason === 'blur' || reason === 'create-option') { // Add in default namespace, etc. let entityRef = ref; try { @@ -85,19 +103,20 @@ export const EntityPicker = (props: EntityPickerProps) => { entityRef = stringifyEntityRef( parseEntityRef(ref as string, { defaultKind, - defaultNamespace: defaultNamespace || undefined, + defaultNamespace, }), ); } catch (err) { // If the passed in value isn't an entity ref, do nothing. } - if (formData !== entityRef) { - onChange(ref); + // We need to check against formData here as that's the previous value for this field. + if (formData !== ref || allowArbitraryValues) { + onChange(entityRef); } } } }, - [onChange, formData, defaultKind, defaultNamespace], + [onChange, formData, defaultKind, defaultNamespace, allowArbitraryValues], ); useEffect(() => { @@ -116,9 +135,10 @@ export const EntityPicker = (props: EntityPickerProps) => { disabled={entities?.length === 1} id={idSchema?.$id} value={ - // Since free solo is usually enabled, attempt to + // Since free solo can be enabled, attempt to parse as a full entity ref first, then fall + // back to the given value. entities?.find(e => stringifyEntityRef(e) === formData) ?? - (allowArbitraryValues ? formData : '') + (allowArbitraryValues && formData ? getLabel(formData) : '') } loading={loading} onChange={onSelect} From 0d61fcca9c37c0b86ac616c30bf150f5d8f39202 Mon Sep 17 00:00:00 2001 From: Aramis Sennyey Date: Thu, 23 Feb 2023 18:10:54 -0500 Subject: [PATCH 4/4] Add changeset. Signed-off-by: Aramis Sennyey --- .changeset/eighty-chairs-roll.md | 5 +++++ .../catalog-react/src/components/EntityRefLink/humanize.ts | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .changeset/eighty-chairs-roll.md 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/catalog-react/src/components/EntityRefLink/humanize.ts b/plugins/catalog-react/src/components/EntityRefLink/humanize.ts index 9bbfcbc1ed..bfc8e2af84 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/humanize.ts +++ b/plugins/catalog-react/src/components/EntityRefLink/humanize.ts @@ -18,7 +18,6 @@ import { Entity, CompoundEntityRef, DEFAULT_NAMESPACE, - UserEntity, } from '@backstage/catalog-model'; /**