Merge pull request #16577 from sennyeya/humanize

Support full entity refs in the EntityPicker
This commit is contained in:
Patrik Oldsberg
2023-03-07 14:06:21 +01:00
committed by GitHub
4 changed files with 167 additions and 100 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': minor
---
Update `EntityPicker` to use the fully qualified entity ref instead of the humanized version.
@@ -236,4 +236,54 @@ describe('<EntityPicker />', () => {
});
});
});
describe('uses full entity ref', () => {
beforeEach(() => {
uiSchema = {
'ui:options': {
defaultKind: 'Group',
},
};
props = {
onChange,
schema,
required,
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps<any>;
catalogApi.getEntities.mockResolvedValue({ items: entities });
});
it('returns the full entityRef when entity exists in the list', async () => {
const { getByRole } = await renderInTestApp(
<Wrapper>
<EntityPicker {...props} />
</Wrapper>,
);
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(
<Wrapper>
<EntityPicker {...props} />
</Wrapper>,
);
const input = getByRole('textbox');
fireEvent.change(input, { target: { value: 'team-b' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenCalledWith('group:default/team-b');
});
});
});
@@ -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 (
<FormControl
@@ -85,14 +132,25 @@ export const EntityPicker = (props: EntityPickerProps) => {
error={rawErrors?.length > 0 && !formData}
>
<Autocomplete
disabled={entityRefs?.length === 1}
disabled={entities?.length === 1}
id={idSchema?.$id}
value={(formData as string) || ''}
value={
// 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 ? 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 => (
<TextField
{...params}
@@ -13,18 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { GetEntitiesResponse } from '@backstage/catalog-client';
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
import { identityApiRef, useApi } from '@backstage/core-plugin-api';
import {
catalogApiRef,
humanizeEntityRef,
} 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, { useMemo } from 'react';
import React from 'react';
import useAsync from 'react-use/lib/useAsync';
import { EntityPicker } from '../EntityPicker/EntityPicker';
import { OwnedEntityPickerProps } from './schema';
@@ -38,98 +33,57 @@ export { OwnedEntityPickerSchema } from './schema';
*/
export const OwnedEntityPicker = (props: OwnedEntityPickerProps) => {
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 (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 0 && !formData}
>
if (loading)
return (
<Autocomplete
id={idSchema?.$id}
value={(formData as string) || ''}
loading={loading}
onChange={onSelect}
options={entityRefs || []}
autoSelect
freeSolo={allowArbitraryValues}
renderInput={params => (
<TextField
{...params}
label={title}
margin="normal"
margin="dense"
helperText={description}
FormHelperTextProps={{ margin: 'dense', style: { marginLeft: 0 } }}
variant="outlined"
required={required}
InputProps={params.InputProps}
/>
)}
options={[]}
/>
</FormControl>
);
return (
<EntityPicker
{...props}
schema={{ title, description }}
allowedKinds={allowedKinds}
catalogFilter={
allowedKinds
? {
filter: {
kind: allowedKinds,
[`relations.${RELATION_OWNED_BY}`]: identityRefs || [],
},
}
: {
filter: {
[`relations.${RELATION_OWNED_BY}`]: identityRefs || [],
},
}
}
/>
);
};
/**
* 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]);
}