feat(EntityPicker): use EntityPresentationApi to display entities in the picker (#24081)

@Julien-Hery Thank you for your contribution! 🎉
This commit is contained in:
Julien Hery
2024-06-07 10:55:34 +02:00
committed by GitHub
parent 768d2c4216
commit d57ebbca65
4 changed files with 106 additions and 26 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': minor
---
Changed the way to display entities in EntityPicker to use entityPresentationApi instead of humanizeEntityRef
@@ -16,14 +16,18 @@
import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import {
CatalogApi,
catalogApiRef,
entityPresentationApiRef,
} from '@backstage/plugin-catalog-react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { fireEvent, screen } from '@testing-library/react';
import React from 'react';
import { EntityPicker } from './EntityPicker';
import { EntityPickerProps } from './schema';
import { ScaffolderRJSFFieldProps as FieldProps } from '@backstage/plugin-scaffolder-react';
import { DefaultEntityPresentationApi } from '@backstage/plugin-catalog';
const makeEntity = (kind: string, namespace: string, name: string): Entity => ({
apiVersion: 'scaffolder.backstage.io/v1beta3',
@@ -50,6 +54,7 @@ describe('<EntityPicker />', () => {
getLocationByRef: jest.fn(),
removeEntityByUid: jest.fn(),
} as any;
let Wrapper: React.ComponentType<React.PropsWithChildren<{}>>;
beforeEach(() => {
@@ -59,7 +64,15 @@ describe('<EntityPicker />', () => {
];
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<TestApiProvider
apis={[
[catalogApiRef, catalogApi],
[
entityPresentationApiRef,
DefaultEntityPresentationApi.create({ catalogApi }),
],
]}
>
{children}
</TestApiProvider>
);
@@ -90,7 +103,12 @@ describe('<EntityPicker />', () => {
);
expect(catalogApi.getEntities).toHaveBeenCalledWith({
fields: ['metadata.name', 'metadata.namespace', 'kind'],
fields: [
'metadata.name',
'metadata.namespace',
'metadata.title',
'kind',
],
filter: undefined,
});
});
@@ -24,13 +24,16 @@ import {
} from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import {
EntityDisplayName,
EntityRefPresentationSnapshot,
catalogApiRef,
humanizeEntityRef,
entityPresentationApiRef,
} from '@backstage/plugin-catalog-react';
import TextField from '@material-ui/core/TextField';
import FormControl from '@material-ui/core/FormControl';
import Autocomplete, {
AutocompleteChangeReason,
createFilterOptions,
} from '@material-ui/lab/Autocomplete';
import React, { useCallback, useEffect } from 'react';
import useAsync from 'react-use/esm/useAsync';
@@ -65,31 +68,55 @@ export const EntityPicker = (props: EntityPickerProps) => {
uiSchema['ui:options']?.defaultNamespace || undefined;
const catalogApi = useApi(catalogApiRef);
const entityPresentationApi = useApi(entityPresentationApiRef);
const { value: entities, loading } = useAsync(async () => {
const fields = ['metadata.name', 'metadata.namespace', 'kind'];
const fields = [
'metadata.name',
'metadata.namespace',
'metadata.title',
'kind',
];
const { items } = await catalogApi.getEntities(
catalogFilter
? { filter: catalogFilter, fields }
: { filter: undefined, fields },
);
return items;
const entityRefToPresentation = new Map<
string,
EntityRefPresentationSnapshot
>(
await Promise.all(
items.map(async item => {
const presentation = await entityPresentationApi.forEntity(item)
.promise;
return [stringifyEntityRef(item), presentation] as [
string,
EntityRefPresentationSnapshot,
];
}),
),
);
return { catalogEntities: items, entityRefToPresentation };
});
const allowArbitraryValues =
uiSchema['ui:options']?.allowArbitraryValues ?? true;
const getLabel = useCallback(
(ref: string) => {
(freeSoloValue: string) => {
try {
return humanizeEntityRef(
parseEntityRef(ref, { defaultKind, defaultNamespace }),
{
defaultKind,
defaultNamespace,
},
);
// Will throw if defaultKind or defaultNamespace are not set
const parsedRef = parseEntityRef(freeSoloValue, {
defaultKind,
defaultNamespace,
});
return stringifyEntityRef(parsedRef);
} catch (err) {
return ref;
return freeSoloValue;
}
},
[defaultKind, defaultNamespace],
@@ -129,12 +156,12 @@ export const EntityPicker = (props: EntityPickerProps) => {
// Since free solo can be enabled, attempt to parse as a full entity ref first, then fall
// back to the given value.
const selectedEntity =
entities?.find(e => stringifyEntityRef(e) === formData) ??
entities?.catalogEntities.find(e => stringifyEntityRef(e) === formData) ??
(allowArbitraryValues && formData ? getLabel(formData) : '');
useEffect(() => {
if (entities?.length === 1 && selectedEntity === '') {
onChange(stringifyEntityRef(entities[0]));
if (entities?.catalogEntities.length === 1 && selectedEntity === '') {
onChange(stringifyEntityRef(entities.catalogEntities[0]));
}
}, [entities, onChange, selectedEntity]);
@@ -145,17 +172,18 @@ export const EntityPicker = (props: EntityPickerProps) => {
error={rawErrors?.length > 0 && !formData}
>
<Autocomplete
disabled={entities?.length === 1}
disabled={entities?.catalogEntities.length === 1}
id={idSchema?.$id}
value={selectedEntity}
loading={loading}
onChange={onSelect}
options={entities || []}
options={entities?.catalogEntities || []}
getOptionLabel={option =>
// option can be a string due to freeSolo.
typeof option === 'string'
? option
: humanizeEntityRef(option, { defaultKind, defaultNamespace })!
: entities?.entityRefToPresentation.get(stringifyEntityRef(option))
?.entityRef!
}
autoSelect
freeSolo={allowArbitraryValues}
@@ -171,6 +199,12 @@ export const EntityPicker = (props: EntityPickerProps) => {
InputProps={params.InputProps}
/>
)}
renderOption={option => <EntityDisplayName entityRef={option} />}
filterOptions={createFilterOptions<Entity>({
stringify: option =>
entities?.entityRefToPresentation.get(stringifyEntityRef(option))
?.primaryTitle!,
})}
/>
</FormControl>
);
@@ -16,11 +16,16 @@
import { type EntityFilterQuery } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import {
CatalogApi,
catalogApiRef,
entityPresentationApiRef,
} from '@backstage/plugin-catalog-react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { ScaffolderRJSFFieldProps as FieldProps } from '@backstage/plugin-scaffolder-react';
import React from 'react';
import { OwnerPicker } from './OwnerPicker';
import { DefaultEntityPresentationApi } from '@backstage/plugin-catalog';
const makeEntity = (kind: string, namespace: string, name: string): Entity => ({
apiVersion: 'backstage.io/v1beta1',
@@ -64,7 +69,15 @@ describe('<OwnerPicker />', () => {
];
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<TestApiProvider
apis={[
[catalogApiRef, catalogApi],
[
entityPresentationApiRef,
DefaultEntityPresentationApi.create({ catalogApi }),
],
]}
>
{children}
</TestApiProvider>
);
@@ -99,7 +112,12 @@ describe('<OwnerPicker />', () => {
filter: {
kind: ['Group', 'User'],
},
fields: ['metadata.name', 'metadata.namespace', 'kind'],
fields: [
'metadata.name',
'metadata.namespace',
'metadata.title',
'kind',
],
}),
);
});
@@ -132,7 +150,12 @@ describe('<OwnerPicker />', () => {
filter: {
kind: ['User'],
},
fields: ['metadata.name', 'metadata.namespace', 'kind'],
fields: [
'metadata.name',
'metadata.namespace',
'metadata.title',
'kind',
],
}),
);
});