From 50df3c87eb1598efc1376eba22a160405c303497 Mon Sep 17 00:00:00 2001 From: Yash Oswal Date: Tue, 29 Oct 2024 11:13:10 +0530 Subject: [PATCH] feat(catalog): Implement breadcrumbs for entity navigation (#26898) updadted tests Signed-off-by: Yash Oswal --- .changeset/afraid-carrots-greet.md | 7 ++ .../components/catalog/EntityPage.test.tsx | 4 +- .../app/src/components/catalog/EntityPage.tsx | 17 ++-- plugins/catalog/report.api.md | 1 + .../EntityLayout/EntityLayout.test.tsx | 32 +++++++ .../components/EntityLayout/EntityLayout.tsx | 92 ++++++++++++++++++- 6 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 .changeset/afraid-carrots-greet.md diff --git a/.changeset/afraid-carrots-greet.md b/.changeset/afraid-carrots-greet.md new file mode 100644 index 0000000000..9f77cba9b8 --- /dev/null +++ b/.changeset/afraid-carrots-greet.md @@ -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. diff --git a/packages/app/src/components/catalog/EntityPage.test.tsx b/packages/app/src/components/catalog/EntityPage.test.tsx index 0943a82efb..06a568279b 100644 --- a/packages/app/src/components/catalog/EntityPage.test.tsx +++ b/packages/app/src/components/catalog/EntityPage.test.tsx @@ -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()], ]} > diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index fe5a7e0ace..11fc60bd0e 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -85,15 +85,14 @@ const customEntityFilterKind = ['Component', 'API', 'System']; const EntityLayoutWrapper = (props: { children?: ReactNode }) => { return ( - <> - - {props.children} - - + + {props.children} + ); }; diff --git a/plugins/catalog/report.api.md b/plugins/catalog/report.api.md index d817118e3a..ffb46afc6e 100644 --- a/plugins/catalog/report.api.md +++ b/plugins/catalog/report.api.md @@ -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) diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx index 239b081051..ff38e53d96 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -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( + + + + +
tabbed-test-content
+
+
+
+
, + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, + }, + }, + ); + + expect(screen.getByText('my-system')).toBeInTheDocument(); + }); + it('navigates when user clicks different tab', async () => { await renderInTestApp( diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index e8b957497d..c06344c2a6 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -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={} pageTitleOverride={headerTitle} type={headerType} + subtitle={ + parentEntity && ( + + {ancestorEntity && ( + + )} + + {name} + + ) + } > {entity && ( <>