Merge pull request #13732 from acierto/entity-ref-link

Provide custom displayed text in EntityRefLinks
This commit is contained in:
Patrik Oldsberg
2022-09-30 13:41:11 +02:00
committed by GitHub
5 changed files with 400 additions and 23 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Added two new `EntityRefLinks` props, the first being `getTitle` that allows for customization of the title used for each link. The second one is `fetchEntities`, which triggers a fetching of all entities so that the full entity definition is available in the `getTitle` callback.
+20 -5
View File
@@ -279,13 +279,28 @@ export type EntityRefLinkProps = {
} & Omit<LinkProps, 'to'>;
// @public
export function EntityRefLinks(props: EntityRefLinksProps): JSX.Element;
export function EntityRefLinks<
TRef extends string | CompoundEntityRef | Entity,
>(props: EntityRefLinksProps<TRef>): JSX.Element;
// @public
export type EntityRefLinksProps = {
entityRefs: (string | Entity | CompoundEntityRef)[];
defaultKind?: string;
} & Omit<LinkProps, 'to'>;
export type EntityRefLinksProps<
TRef extends string | CompoundEntityRef | Entity,
> = (
| {
defaultKind?: string;
entityRefs: TRef[];
fetchEntities?: false;
getTitle?(entity: TRef): string | undefined;
}
| {
defaultKind?: string;
entityRefs: TRef[];
fetchEntities: true;
getTitle(entity: Entity): string | undefined;
}
) &
Omit<LinkProps, 'to'>;
// @public
export function entityRouteParams(entity: Entity): {
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -13,41 +13,72 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
import React from 'react';
import { EntityRefLink } from './EntityRefLink';
import { LinkProps } from '@backstage/core-components';
import { FetchedEntityRefLinks } from './FetchedEntityRefLinks';
/**
* Props for {@link EntityRefLink}.
*
* @public
*/
export type EntityRefLinksProps = {
entityRefs: (string | Entity | CompoundEntityRef)[];
defaultKind?: string;
} & Omit<LinkProps, 'to'>;
export type EntityRefLinksProps<
TRef extends string | CompoundEntityRef | Entity,
> = (
| {
defaultKind?: string;
entityRefs: TRef[];
fetchEntities?: false;
getTitle?(entity: TRef): string | undefined;
}
| {
defaultKind?: string;
entityRefs: TRef[];
fetchEntities: true;
getTitle(entity: Entity): string | undefined;
}
) &
Omit<LinkProps, 'to'>;
/**
* Shows a list of clickable links to entities.
*
* @public
*/
export function EntityRefLinks(props: EntityRefLinksProps) {
const { entityRefs, defaultKind, ...linkProps } = props;
export function EntityRefLinks<
TRef extends string | CompoundEntityRef | Entity,
>(props: EntityRefLinksProps<TRef>) {
const { entityRefs, defaultKind, fetchEntities, getTitle, ...linkProps } =
props;
if (fetchEntities) {
return (
<FetchedEntityRefLinks
{...linkProps}
defaultKind={defaultKind}
entityRefs={entityRefs}
getTitle={getTitle}
/>
);
}
return (
<>
{entityRefs.map((r, i) => (
<React.Fragment key={i}>
{i > 0 && ', '}
<EntityRefLink
{...linkProps}
entityRef={r}
defaultKind={defaultKind}
/>
</React.Fragment>
))}
{entityRefs.map((r: TRef, i: number) => {
return (
<React.Fragment key={i}>
{i > 0 && ', '}
<EntityRefLink
{...linkProps}
defaultKind={defaultKind}
entityRef={r}
title={getTitle ? getTitle(r) : undefined}
/>
</React.Fragment>
);
})}
</>
);
}
@@ -0,0 +1,218 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FetchedEntityRefLinks } from './FetchedEntityRefLinks';
import { entityRouteRef } from '../../routes';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { Entity } from '@backstage/catalog-model';
import React from 'react';
import { JsonObject } from '@backstage/types';
import { catalogApiRef } from '../../api';
import { CatalogApi } from '@backstage/catalog-client';
describe('<FetchedEntityRefLinks />', () => {
const getTitle = (e: Entity): string =>
(e.spec?.profile!! as JsonObject).displayName!!.toString()!!;
it('should fetch entities and render the custom display text', async () => {
const entityRefs = [
{
kind: 'Component',
namespace: 'default',
name: 'software',
},
{
kind: 'API',
namespace: 'default',
name: 'interface',
},
];
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve({
items: entityRefs.map(ref => ({
apiVersion: 'backstage.io/v1alpha1',
kind: ref.kind,
metadata: {
name: ref.name,
namespace: ref.namespace,
},
spec: {
profile: {
displayName: ref.name.toLocaleUpperCase('en-US'),
},
type: 'organization',
},
})),
}),
};
const rendered = await renderInTestApp(
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<FetchedEntityRefLinks entityRefs={entityRefs} getTitle={getTitle} />
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
},
},
);
expect(rendered.getByText('SOFTWARE')).toHaveAttribute(
'href',
'/catalog/default/component/software',
);
expect(rendered.getByText('INTERFACE')).toHaveAttribute(
'href',
'/catalog/default/api/interface',
);
});
it('should use entities as they are provided and render the custom display text', async () => {
const entityRefs = [
{
kind: 'Component',
namespace: 'default',
name: 'tool',
},
{
kind: 'API',
namespace: 'default',
name: 'implementation',
},
].map(ref => ({
apiVersion: 'backstage.io/v1alpha1',
kind: ref.kind,
metadata: {
name: ref.name,
namespace: ref.namespace,
},
spec: {
profile: {
displayName: ref.name.toLocaleUpperCase('en-US'),
},
type: 'organization',
},
}));
const catalogApi: Partial<CatalogApi> = {};
const rendered = await renderInTestApp(
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<FetchedEntityRefLinks entityRefs={entityRefs} getTitle={getTitle} />
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
},
},
);
expect(rendered.getByText('TOOL')).toHaveAttribute(
'href',
'/catalog/default/component/tool',
);
expect(rendered.getByText('IMPLEMENTATION')).toHaveAttribute(
'href',
'/catalog/default/api/implementation',
);
});
it('should handle heterogeneous array of values to render the custom display text', async () => {
const entityRefs = [
...[
{
kind: 'Component',
namespace: 'default',
name: 'tool',
},
{
kind: 'API',
namespace: 'default',
name: 'implementation',
},
].map(ref => ({
apiVersion: 'backstage.io/v1alpha1',
kind: ref.kind,
metadata: {
name: ref.name,
namespace: ref.namespace,
},
spec: {
profile: {
displayName: ref.name.toLocaleUpperCase('en-US'),
},
type: 'organization',
},
})),
{
kind: 'Component',
namespace: 'default',
name: 'interface',
},
];
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'interface',
namespace: 'default',
},
spec: {
profile: {
displayName: 'INTERFACE',
},
type: 'organization',
},
},
],
}),
};
const rendered = await renderInTestApp(
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<FetchedEntityRefLinks entityRefs={entityRefs} getTitle={getTitle} />
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
},
},
);
expect(rendered.getByText('TOOL')).toHaveAttribute(
'href',
'/catalog/default/component/tool',
);
expect(rendered.getByText('IMPLEMENTATION')).toHaveAttribute(
'href',
'/catalog/default/api/implementation',
);
expect(rendered.getByText('INTERFACE')).toHaveAttribute(
'href',
'/catalog/default/component/interface',
);
});
});
@@ -0,0 +1,108 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Entity,
CompoundEntityRef,
parseEntityRef,
} from '@backstage/catalog-model';
import React from 'react';
import { EntityRefLink } from './EntityRefLink';
import { ErrorPanel, LinkProps, Progress } from '@backstage/core-components';
import useAsync from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../api';
import { useApi } from '@backstage/core-plugin-api';
/**
* Props for {@link FetchedEntityRefLinks}.
*
* @public
*/
export type FetchedEntityRefLinksProps<
TRef extends string | CompoundEntityRef | Entity,
> = {
defaultKind?: string;
entityRefs: TRef[];
getTitle(entity: Entity): string | undefined;
} & Omit<LinkProps, 'to'>;
/**
* Shows a list of clickable links to entities with auto-fetching of entities
* for customising a displayed text via title attribute.
*
* @public
*/
export function FetchedEntityRefLinks<
TRef extends string | CompoundEntityRef | Entity,
>(props: FetchedEntityRefLinksProps<TRef>) {
const { entityRefs, defaultKind, getTitle, ...linkProps } = props;
const catalogApi = useApi(catalogApiRef);
const {
value: entities = new Array<Entity>(),
loading,
error,
} = useAsync(async () => {
const refs = entityRefs.reduce((acc, current) => {
return 'metadata' in current ? acc : [...acc, parseEntityRef(current)];
}, new Array<CompoundEntityRef>());
const pureEntities = entityRefs.filter(
ref => 'metadata' in ref,
) as Array<Entity>;
return refs.length > 0
? [
...(
await catalogApi.getEntities({
filter: refs.map(ref => ({
kind: ref.kind,
'metadata.namespace': ref.namespace,
'metadata.name': ref.name,
})),
})
).items,
...pureEntities,
]
: pureEntities;
}, [entityRefs]);
if (loading) {
return <Progress />;
}
if (error) {
return <ErrorPanel error={error} />;
}
return (
<>
{entities.map((r: Entity, i) => {
return (
<React.Fragment key={i}>
{i > 0 && ', '}
<EntityRefLink
{...linkProps}
defaultKind={defaultKind}
entityRef={r}
title={getTitle(r as Entity)}
/>
</React.Fragment>
);
})}
</>
);
}