Merge pull request #26899 from yashoswalyo/catalog-feat-26898
feat(catalog): Implement breadcrumbs for entity navigation
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': minor
|
||||
---
|
||||
|
||||
- Updated EntityLayout component to implement breadcrumb navigation based on the entity relations.
|
||||
|
||||
- Added parentEntityRelations prop to EntityLayoutProps to specify relation types for parent entities.
|
||||
@@ -13,12 +13,12 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { EntityLayout, catalogPlugin } from '@backstage/plugin-catalog';
|
||||
import {
|
||||
EntityProvider,
|
||||
starredEntitiesApiRef,
|
||||
MockStarredEntitiesApi,
|
||||
catalogApiRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { permissionApiRef } from '@backstage/plugin-permission-react';
|
||||
import {
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { cicdContent } from './EntityPage';
|
||||
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
|
||||
|
||||
describe('EntityPage Test', () => {
|
||||
const entity = {
|
||||
@@ -55,6 +56,7 @@ describe('EntityPage Test', () => {
|
||||
apis={[
|
||||
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
|
||||
[permissionApiRef, mockApis.permission()],
|
||||
[catalogApiRef, catalogApiMock()],
|
||||
]}
|
||||
>
|
||||
<EntityProvider entity={entity}>
|
||||
|
||||
@@ -85,15 +85,14 @@ const customEntityFilterKind = ['Component', 'API', 'System'];
|
||||
|
||||
const EntityLayoutWrapper = (props: { children?: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<EntityLayout
|
||||
UNSTABLE_contextMenuOptions={{
|
||||
disableUnregister: 'visible',
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</EntityLayout>
|
||||
</>
|
||||
<EntityLayout
|
||||
parentEntityRelations={['partOf', 'memberOf', 'childOf']}
|
||||
UNSTABLE_contextMenuOptions={{
|
||||
disableUnregister: 'visible',
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</EntityLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -414,6 +414,7 @@ export interface EntityLayoutProps {
|
||||
children?: React_2.ReactNode;
|
||||
// (undocumented)
|
||||
NotFoundComponent?: React_2.ReactNode;
|
||||
parentEntityRelations?: string[];
|
||||
// Warning: (ae-forgotten-export) The symbol "EntityContextMenuOptions" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// (undocumented)
|
||||
|
||||
@@ -169,6 +169,38 @@ describe('EntityLayout', () => {
|
||||
expect(screen.queryByText('tabbed-test-content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the breadcrumbs if defined', async () => {
|
||||
const mockEntityWithRelation = {
|
||||
kind: 'MyKind',
|
||||
metadata: {
|
||||
name: 'my-entity',
|
||||
namespace: 'default',
|
||||
title: 'My Entity',
|
||||
},
|
||||
relations: [{ type: 'partOf', targetRef: 'system:default/my-system' }],
|
||||
} as Entity;
|
||||
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<EntityProvider entity={mockEntityWithRelation}>
|
||||
<EntityLayout parentEntityRelations={['partOf']}>
|
||||
<EntityLayout.Route path="/" title="tabbed-test-title">
|
||||
<div>tabbed-test-content</div>
|
||||
</EntityLayout.Route>
|
||||
</EntityLayout>
|
||||
</EntityProvider>
|
||||
</ApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
'/catalog': rootRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('my-system')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates when user clicks different tab', async () => {
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
DEFAULT_NAMESPACE,
|
||||
Entity,
|
||||
EntityRelation,
|
||||
RELATION_OWNED_BY,
|
||||
} from '@backstage/catalog-model';
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Content,
|
||||
Header,
|
||||
HeaderLabel,
|
||||
@@ -32,12 +34,16 @@ import {
|
||||
import {
|
||||
attachComponentData,
|
||||
IconComponent,
|
||||
useApi,
|
||||
useElementFilter,
|
||||
useRouteRef,
|
||||
useRouteRefParams,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
import {
|
||||
catalogApiRef,
|
||||
EntityDisplayName,
|
||||
EntityRefLink,
|
||||
EntityRefLinks,
|
||||
entityRouteRef,
|
||||
FavoriteEntity,
|
||||
@@ -47,14 +53,15 @@ import {
|
||||
useAsyncEntity,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import Box from '@material-ui/core/Box';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { TabProps } from '@material-ui/core/Tab';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
|
||||
import { rootRouteRef, unregisterRedirectRouteRef } from '../../routes';
|
||||
import useAsync from 'react-use/esm/useAsync';
|
||||
import { catalogTranslationRef } from '../../alpha/translation';
|
||||
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
import { rootRouteRef, unregisterRedirectRouteRef } from '../../routes';
|
||||
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
|
||||
|
||||
/** @public */
|
||||
export type EntityLayoutRouteProps = {
|
||||
@@ -101,6 +108,7 @@ function headerProps(
|
||||
const namespace = paramNamespace ?? entity?.metadata.namespace ?? '';
|
||||
const name =
|
||||
entity?.metadata.title ?? paramName ?? entity?.metadata.name ?? '';
|
||||
|
||||
return {
|
||||
headerTitle: `${name}${
|
||||
namespace && namespace !== DEFAULT_NAMESPACE ? ` in ${namespace}` : ''
|
||||
@@ -167,8 +175,49 @@ export interface EntityLayoutProps {
|
||||
UNSTABLE_contextMenuOptions?: EntityContextMenuOptions;
|
||||
children?: React.ReactNode;
|
||||
NotFoundComponent?: React.ReactNode;
|
||||
/**
|
||||
* An array of relation types used to determine the parent entities in the hierarchy.
|
||||
* These relations are prioritized in the order provided, allowing for flexible
|
||||
* navigation through entity relationships.
|
||||
*
|
||||
* For example, use relation types like `["partOf", "memberOf", "ownedBy"]` to define how the entity is related to
|
||||
* its parents in the Entity Catalog.
|
||||
*
|
||||
* It adds breadcrumbs in the Entity page to enhance user navigation and context awareness.
|
||||
*/
|
||||
parentEntityRelations?: string[];
|
||||
}
|
||||
|
||||
function findParentRelation(
|
||||
entityRelations: EntityRelation[] = [],
|
||||
relationTypes: string[] = [],
|
||||
) {
|
||||
for (const type of relationTypes) {
|
||||
const foundRelation = entityRelations.find(
|
||||
relation => relation.type === type,
|
||||
);
|
||||
if (foundRelation) {
|
||||
return foundRelation; // Return the first found relation and stop
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
breadcrumbs: {
|
||||
color: theme.page.fontColor,
|
||||
fontSize: theme.typography.caption.fontSize,
|
||||
textTransform: 'uppercase',
|
||||
marginTop: theme.spacing(1),
|
||||
opacity: 0.8,
|
||||
'& span ': {
|
||||
color: theme.page.fontColor,
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: '3px',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* EntityLayout is a compound component, which allows you to define a layout for
|
||||
* entities using a sub-navigation mechanism.
|
||||
@@ -192,7 +241,9 @@ export const EntityLayout = (props: EntityLayoutProps) => {
|
||||
UNSTABLE_contextMenuOptions,
|
||||
children,
|
||||
NotFoundComponent,
|
||||
parentEntityRelations,
|
||||
} = props;
|
||||
const classes = useStyles();
|
||||
const { kind, namespace, name } = useRouteRefParams(entityRouteRef);
|
||||
const { entity, loading, error } = useAsyncEntity();
|
||||
const location = useLocation();
|
||||
@@ -247,6 +298,22 @@ export const EntityLayout = (props: EntityLayoutProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const parentEntity = findParentRelation(
|
||||
entity?.relations ?? [],
|
||||
parentEntityRelations ?? [],
|
||||
);
|
||||
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { value: ancestorEntity } = useAsync(async () => {
|
||||
if (parentEntity) {
|
||||
return findParentRelation(
|
||||
(await catalogApi.getEntityByRef(parentEntity?.targetRef))?.relations,
|
||||
parentEntityRelations,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [parentEntity]);
|
||||
|
||||
// Make sure to close the dialog if the user clicks links in it that navigate
|
||||
// to another entity.
|
||||
useEffect(() => {
|
||||
@@ -261,6 +328,23 @@ export const EntityLayout = (props: EntityLayoutProps) => {
|
||||
title={<EntityLayoutTitle title={headerTitle} entity={entity!} />}
|
||||
pageTitleOverride={headerTitle}
|
||||
type={headerType}
|
||||
subtitle={
|
||||
parentEntity && (
|
||||
<Breadcrumbs separator=">" className={classes.breadcrumbs}>
|
||||
{ancestorEntity && (
|
||||
<EntityRefLink
|
||||
entityRef={ancestorEntity.targetRef}
|
||||
disableTooltip
|
||||
/>
|
||||
)}
|
||||
<EntityRefLink
|
||||
entityRef={parentEntity.targetRef}
|
||||
disableTooltip
|
||||
/>
|
||||
{name}
|
||||
</Breadcrumbs>
|
||||
)
|
||||
}
|
||||
>
|
||||
{entity && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user