Merge pull request #16577 from sennyeya/humanize
Support full entity refs in the EntityPicker
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user